# Security

Reference for all security-relevant design decisions, mitigations, and known trade-offs. Cross-references to `docs/atproto-auth.md` (OAuth/DPoP) and `docs/api-auth.md` (service JWT verification) where those topics are already fully documented.

---

## Content security (XSS)

User-generated content is the entire surface area of the app. Every rendering path is covered separately below.

### Markdown pipeline

Posts are written in Markdown and rendered through a unified/remark/rehype pipeline (`app/lib/markdownProcessor.ts`). The pipeline order is deliberately:

```
remarkParse → remarkGfm → remarkMath
  → remarkRehype(allowDangerousHtml: true)   ← passes raw HTML nodes through
  → rehypeKatex / rehypeAbc / rehypeMermaid / rehypeFountain
  → rehypeFunctionPlot / rehypeIframely / rehypeSpoiler
  → rehypePrettyCode
  → rehypeRaw                                ← materialises raw HTML nodes into the tree
  → rehypeSanitize(customSchema)             ← strips everything not on the allowlist
  → rehypeStringify
```

`rehypeSanitize` always runs last. `allowDangerousHtml: true` in `remarkRehype` is necessary for KaTeX and the rich-block plugins to emit HTML, but it is harmless because `rehypeSanitize` strips anything dangerous before the HTML ever leaves the pipeline.

**Custom sanitization schema** (`app/lib/markdownProcessor.ts → createSanitizeSchema`):

- Extends `defaultSchema` (hast-util-sanitize).
- Tag allowlist adds only what the active plugins need: MathML elements for KaTeX, `details`/`summary` for collapsible source blocks, `figure`/`pre`/`code`/`span` for syntax highlighting.
- Attribute allowlist is per-tag and regex-constrained (e.g. `className` on `span` only matches `[\w\s-]+|spoiler-text|blur`).
- Protocol allowlist: `href` → `http`, `https`, `mailto` only; `src` → `http`, `https` only. `javascript:` links and `data:` image URLs are stripped.

### Client-side rich blocks

Mermaid diagrams, Fountain screenplays, ABC notation, and function plots are processed in two phases, both running in the browser (there is no server runtime):

**Phase 1 — unified pipeline** (`markdownProcessor.ts`): runs when the user previews or saves a post. The rehype plugin replaces the fenced code block with a wrapper `<div>` carrying a `data-*` attribute holding the raw source string. `rehypeSanitize` then allows these specific `data-*` attributes and validates the class names. The output is an HTML string. The split is necessary because rich rendering libraries need a live DOM and cannot run inside a string-based HTML pipeline.

**Phase 2 — React rendering** (`PreviewRenderer.tsx`): runs when the component mounts or the HTML string changes. `PreviewRenderer` scans the DOM for `data-*` wrappers and invokes the appropriate library (Mermaid, abcjs, fountain-js, function-plot) to render into the real DOM.

Each rendering path has specific mitigations:

| Block | Library | Output | Sanitization |
|-------|---------|--------|-------------|
| Mermaid | mermaid (securityLevel: `strict`) | SVG string | No DOMPurify — Mermaid renders in a sandboxed iframe and strips scripts before returning the SVG. DOMPurify's SVG profile would strip the `<foreignObject>` HTML that Mermaid uses for node labels, removing all text from diagrams. |
| Fountain | fountain-js | HTML string | `DOMPurify.sanitize(html)` before `innerHTML` — fountain-js does **not** HTML-encode user content, so raw tags in screenplay text pass through without this step |
| ABC notation | abcjs | Direct DOM (SVG via API) | Library writes DOM nodes programmatically, not via innerHTML |
| Function plot | function-plot + mathjs | Canvas/SVG via API | mathjs `parse()` validates expressions; library writes DOM nodes programmatically |

**Mermaid pre-validation:** before the source is stored in the `data-mermaid-code` attribute, `validateMermaidSyntax` (`app/lib/security-constants.ts`) rejects strings matching patterns like `<script>`, `javascript:`, `onerror=`, `onload=`, and `<iframe>`/`<embed>`/`<object>`. This is defence-in-depth alongside `securityLevel: 'strict'` and DOMPurify.

**Code block length limit:** all rich-block rehype plugins reject source code exceeding `MAX_CODE_LENGTH` (50,000 characters) before processing.

### Iframely embeds

The `embed` fenced code block is handled separately from the markdown pipeline sanitization because it renders third-party HTML:

1. **SSRF prevention** (see [SSRF prevention](#ssrf-prevention) below): the URL is validated before it ever reaches the API.
2. **Unified pipeline** (`rehype-iframely`): only stores the URL in a `data-embed-url` attribute — no network call, no HTML, nothing to sanitize at this stage.
3. **Client-side** (`IframelyEmbed`): fetches `GET /iframely?url=…` from the API Lambda. The API Lambda fetches from iframely's API and caches the response.
4. **DOMPurify**: the iframely HTML response is sanitized with `DOMPurify.sanitize(html, { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] })` before `dangerouslySetInnerHTML` (`app/lib/iframely-utils.ts`). This strips event handlers and `javascript:` URLs while preserving the iframe element.
5. **SRI**: `cdn.iframe.ly/embed.js` is loaded with a `integrity="sha256-…"` attribute. The browser verifies the hash before executing the script. The hash is pinned in `app/components/IframelyEmbed.tsx` and must be updated when iframely ships a new CDN build.

### React rendering

All user-generated strings rendered as JSX children (post titles, display names, handles, reply text, publication names, error messages) are automatically HTML-escaped by React. None of these values are passed to `dangerouslySetInnerHTML` or `innerHTML`.

---

## API authorization

Full JWT verification details are in `docs/api-auth.md`. Summary of the API Lambda's authorization model:

### Service JWT verification

Every authenticated endpoint verifies the ATProto service JWT before any business logic runs (`infra/api/src/index.ts → verifyServiceToken`):

1. Structural check: must be a three-part `.`-delimited string.
2. `iss` must be a DID (`did:` prefix).
3. `aud` must include `API_DID` (environment variable set at deploy time).
4. `exp` must be in the future (Unix seconds).
5. Cryptographic signature: the issuer's atproto signing key is resolved via `@atproto/identity` `IdResolver`, then `@atproto/crypto` `verifySignature` checks the signature over the `header.payload` bytes.

Only after all five checks pass is the `iss` DID accepted as the authenticated caller identity.

### DynamoDB isolation

Every authenticated write scopes DynamoDB keys to the verified DID:

```
Post items:        PK = u#<verified-did>#post
Publication items: PK = u#<verified-did>#pub
```

The `did` variable is set exclusively from the JWT `iss` claim after `verifyServiceToken` succeeds. No user-supplied field in the request body can change which partition is written. A user cannot write to, update, or delete another user's items.

Public read endpoints (`GET /feed`, `GET /profile`, `GET /iframely`) require no token and only read data. `GET /profile` validates that the `did` query parameter starts with `did:` before querying.

### Input validation

- Required fields (`atUri`, `site`, `rkey`, `title` for posts; `atUri`, `url`, `name`, `rkey` for publications) are checked and return 400 if missing or blank.
- `bskyPostRef`, if present, must be a non-null object with string `uri` (must start with `at://`, max 512 chars) and string `cid` (max 256 chars). The DID embedded in the `uri` (`at://<did>/…`) must match the authenticated user's DID — a user cannot point their document at another user's Bluesky post. Invalid structure or DID mismatch returns 400. The indexer applies the same ownership check when reading `bskyPostRef` from AT Protocol records.
- Feed cursor is base64url-decoded JSON; malformed values return 400.
- `visibility` is coerced to `'draft'` or `'published'` — arbitrary strings are normalised.

### Rate limiting

API Gateway throttling applies to all routes:

| Tier | Rate | Burst |
|------|------|-------|
| Steady | 50 req/s | 100 req |

Requests beyond the burst limit receive HTTP 429 before the API Lambda is invoked. This applies to both the public iframely endpoint and authenticated write endpoints.

---

## SSRF prevention

### Client-side embed URL validation

`rehype-iframely` (`app/lib/rehype-iframely.ts`) validates every embed URL before converting the code block to a wrapper element:

- **Protocol**: must be `http:` or `https:`.
- **Length**: URL must not exceed 2,048 characters.
- **Private IP ranges**: rejects URLs whose hostname matches any of:
  - Loopback: `localhost`, `127.x.x.x`, `[::1]`, `[0:0:0:0:0:0:0:1]`
  - RFC 1918: `10.x.x.x`, `172.16–31.x.x`, `192.168.x.x`
  - Link-local / AWS metadata endpoint: `169.254.x.x`
  - Broadcast / unspecified: `0.0.0.0`, `255.255.255.255`

If any check fails, the code block is replaced with an error element and no URL is stored in the DOM.

Note: the validation runs client-side (during markdown-to-HTML processing in the browser). The API Lambda `/iframely` endpoint does not fetch the user URL directly — it forwards the URL to iframely's API, which performs its own domain whitelisting.

### API Lambda PDS URL validation

When the API Lambda resolves a publication's metadata to display in the feed, it fetches a record from the publication author's PDS (`infra/api/src/index.ts → resolvePublicationMeta`). The PDS URL is read from the DID document returned by the ATProto identity resolver. A `did:web:` controlled by an attacker could claim a PDS at an internal AWS address.

Before making the fetch, the API Lambda validates:

1. The URL must use `https:` protocol.
2. The hostname must not match loopback, RFC 1918, link-local (`169.254.x.x` — the AWS EC2 metadata endpoint), IPv6 loopback, or unspecified addresses.

If either check fails, the request is rejected with an error rather than making the outbound HTTP call.

---

## Infrastructure security

### IAM least privilege

The API Lambda execution role (`blog-api-lambda`) has a single inline policy granting only what the API Lambda actually uses:

```
dynamodb:PutItem, GetItem, UpdateItem, DeleteItem, Query
```

on the `blogs` table and its indexes. No `Scan`, no `BatchWrite`, no cross-table access. An optional second policy is conditionally added when `IframelySecretArn` is provided:

```
secretsmanager:GetSecretValue
```

on the specific secret ARN only (not `*`).

The indexer EC2 instance role grants `dynamodb:PutItem`, `GetItem`, `DeleteItem`, `Query` on the same table, plus `s3:GetObject` scoped to the deployment bucket for the initial bundle download.

### Secrets Management

The iframely API key is stored in AWS Secrets Manager, not in environment variables or source code. The API Lambda fetches it once and caches it in the API Lambda process memory for subsequent warm invocations. The browser never receives the key.

### EC2 indexer network isolation

The indexer runs in a public subnet with a security group that has **zero inbound rules**. The public IP is unreachable from the internet. Outbound-only access is sufficient to consume the Jetstream WebSocket. Shell access uses SSM Session Manager — no open ports, no SSH key pair.

### DynamoDB

- **Point-in-time recovery** is enabled with a 30-day recovery window.
- **TTL** is enabled on the `ttl` attribute (used by the iframely cache to expire entries automatically).
- **CloudWatch log group** for the API Lambda has a 30-day retention policy.

---

## CORS

The API Lambda sets `Access-Control-Allow-Origin: *` on all responses. Since authentication uses `Authorization: Bearer <token>` rather than cookies, the wildcard origin does not enable cross-origin credential forwarding — `*` and `credentials: 'include'` are mutually exclusive in the Fetch spec. Any origin can call the public read endpoints, which is intentional (they expose public blog content). Authenticated write endpoints require a valid ATProto service JWT which can only be obtained by the legitimate user from their own PDS.

`deploy-aws.sh` sets `AllowedOrigin` automatically to the CloudFront site URL (`SiteUrl` output of the CloudFront stack). On a first run, CloudFront doesn't exist yet, so the API stack is deployed twice: once with `*` as a fallback, then re-deployed with the real CloudFront URL once the CloudFront stack is up.

---

## OAuth and session security

Covered in full in `docs/atproto-auth.md`. Key points:

- **PKCE**: the OAuth authorization code flow uses PKCE, preventing authorization code interception attacks.
- **DPoP**: all ATProto access tokens are DPoP-bound. The library generates an in-memory key pair; each request carries a proof JWT binding the token to the specific HTTP method and URL. A stolen access token cannot be replayed without the private key.
- **IndexedDB session storage**: the OAuth session (tokens, DPoP key) is stored in IndexedDB, not `localStorage` or cookies. IndexedDB is origin-isolated; other origins cannot read it.
- **Session revocation hook**: the `onDelete` callback in `app/lib/auth-context.tsx` fires when the PDS revokes the session (e.g. password change on another device) and clears React state immediately.
- **Short-lived service JWTs**: ~60-second expiry limits the window for token replay. A fresh JWT is requested before each API call.

---

## Known limitations and accepted risks

| Issue | Impact | Rationale |
|-------|--------|-----------|
| `transition:generic` OAuth scope in local dev | Broad PDS write access during local development | Production uses enumerated `rpc:` scopes covering only the specific operations the app needs (see `docs/atproto-auth.md`). `transition:generic` is required for local dev because the PDS cannot reach `localhost` to validate a `did:web` service reference. |
| `publishedAt` is user-controlled | Users can set a past date to influence global feed ordering | Future dates are mitigated: `GSI1SK` is computed as `min(publishedAt, now)` at index time, so a record with a far-future `publishedAt` sorts no higher than the moment it was indexed. Past dates are intentional — the backfill flow must preserve original publication timestamps. |
| Handle resolution via `bsky.social` | Leaks user handles and IPs to Bluesky during sign-in | Documented in `docs/atproto-auth.md`. DNS-over-HTTPS (`cloudflare-dns.com`) is the recommended alternative. |
| No `Content-Security-Policy` header | Reduces defence-in-depth against XSS | CloudFront is in use but no Response Headers Policy or CloudFront Function is configured to inject the header. |
| Mermaid/Fountain code stored in `data-*` DOM attributes | The raw source code of all rich blocks is visible in the DOM | Intentional: the rehype plugin stores source so the client-side renderer can process it. The content is already in the post body and is public. |
