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/summaryfor collapsible source blocks,figure/pre/code/spanfor syntax highlighting. - Attribute allowlist is per-tag and regex-constrained (e.g.
classNameonspanonly matches[\w\s-]+|spoiler-text|blur). - Protocol allowlist:
href→http,https,mailtoonly;src→http,httpsonly.javascript:links anddata: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:
- SSRF prevention (see SSRF prevention below): the URL is validated before it ever reaches the API.
- Unified pipeline (
rehype-iframely): only stores the URL in adata-embed-urlattribute — no network call, no HTML, nothing to sanitize at this stage. - Client-side (
IframelyEmbed): fetchesGET /iframely?url=…from the API Lambda. The API Lambda fetches from iframely's API and caches the response. - DOMPurify: the iframely HTML response is sanitized with
DOMPurify.sanitize(html, { ADD_TAGS: ['iframe'], ADD_ATTR: ['allow', 'allowfullscreen', 'frameborder', 'scrolling'] })beforedangerouslySetInnerHTML(app/lib/iframely-utils.ts). This strips event handlers andjavascript:URLs while preserving the iframe element. - SRI:
cdn.iframe.ly/embed.jsis loaded with aintegrity="sha256-…"attribute. The browser verifies the hash before executing the script. The hash is pinned inapp/components/IframelyEmbed.tsxand 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):
- Structural check: must be a three-part
.-delimited string. issmust be a DID (did:prefix).audmust includeAPI_DID(environment variable set at deploy time).expmust be in the future (Unix seconds).- Cryptographic signature: the issuer's atproto signing key is resolved via
@atproto/identityIdResolver, then@atproto/cryptoverifySignaturechecks the signature over theheader.payloadbytes.
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,titlefor posts;atUri,url,name,rkeyfor publications) are checked and return 400 if missing or blank. bskyPostRef, if present, must be a non-null object with stringuri(must start withat://, max 512 chars) and stringcid(max 256 chars). The DID embedded in theuri(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 readingbskyPostReffrom AT Protocol records.- Feed cursor is base64url-decoded JSON; malformed values return 400.
visibilityis 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:orhttps:. - 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
- Loopback:
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:
- The URL must use
https:protocol. - 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
ttlattribute (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
localStorageor cookies. IndexedDB is origin-isolated; other origins cannot read it. - Session revocation hook: the
onDeletecallback inapp/lib/auth-context.tsxfires 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. |