ADR-0038 — F11 Web Clipper: secure clip ingress model¶
- Status: Accepted
- Date: 2026-06-30
- Sprint: v0.6 (M6 — shippable; F11 Web Clipper + secure local ingress)
- Features: F11 (Chrome MV3 web clipper + secure local clip ingress + auto-ingest) · K1 (3-layer vault: raw/ → wiki/) · I1 (incremental index only) · I5 (atomic vault writes; Obsidian-valid wiki/) · I7 (bounded; body cap enforced)
- Reference: R1 (nashsu/llm_wiki — the clip server this ADR explicitly improves upon) · docs/reference/llm_wiki-audit/02-CODE-UI-REVIEW.md §1 (findings S-1..S-6)
- Invariants owned: I5 (atomic write via temp+os.replace; wiki/ never touched directly) · I1 (watcher's mtime/SHA gate deduplicates re-clips; no double-ingest) · I7 (body cap prevents DoS; CLIP_MAX_BODY_BYTES env-configured)
- Author: backend-engineer · solution-architect
1. Context¶
F11 adds a Chrome MV3 web clipper that converts pages to Markdown (via Readability +
Turndown in the browser) and sends them to a Synapse ingress endpoint for auto-ingest into
vault/raw/sources/.
The reference app nashsu/llm_wiki ships a clip server (:19827) with five critical
security defects documented in docs/reference/llm_wiki-audit/02-CODE-UI-REVIEW.md §1:
| Audit ID | Defect | Severity |
|---|---|---|
| S-1 | Clip server completely unauthenticated | Critical |
| S-2 | Caller controls the base directory (project_path) — writes to arbitrary paths |
Critical |
| S-3 | Drive-by cross-origin POST: CORS is checked only for response headers, not to gate the action; any visited website can trigger ingest of attacker-controlled content | Critical |
| S-4 | LAN exposure with no token (inherits bind host from the API server) | High |
| S-5 | No body size cap — trivial DoS | High |
| S-6 | Content not sanitised before reaching the LLM pipeline | Medium |
Synapse must not repeat any of these. This ADR documents the security model that closes all six gaps.
2. Decision¶
2.1 Single service — no second server (S-4 closed by architecture)¶
The clip ingress is POST /clip on the existing FastAPI service — not a second process
on a separate port. This eliminates S-4 by construction: the existing service binds on the
operator's configured host:port (not 0.0.0.0 by default), benefits from all the existing
security controls (rate-limiting, reverse proxy, Tailscale), and adds no new network surface.
The reference app's :19827 separate server was the root cause of S-4: it ran with different
(unauthenticated) logic from the API on :19828. Synapse has exactly ONE service.
2.2 AuthN: CLIP_TOKEN (closes S-1)¶
Every POST /clip request must carry Authorization: Bearer <CLIP_TOKEN>. The token is:
- Configured via the CLIP_TOKEN env var (never hardcoded).
- Compared constant-time (hmac.compare_digest) to prevent timing side-channels.
- Never logged, never returned in any API response.
- Absent → 401; wrong → 401. Fail-closed (no configured token = always 401).
An optional master gate CLIP_ENABLED (default false) allows the ingress to be disabled
entirely even when the FastAPI app is running. When CLIP_ENABLED=false → 503 before any
auth check.
2.3 Origin/Host allowlist (closes S-3)¶
CORS alone does not protect against drive-by POST writes. A "simple" POST with
Content-Type: application/json does not trigger a preflight check in some browser contexts,
and even when it does, the CORS gate only controls response headers — the server action has
already executed by the time the browser sees the response.
Therefore, the server validates the Origin header before any processing or disk write:
allowlist = CLIP_ALLOWED_ORIGINS (env, comma-separated) ∪ loopback origins (implicit)
Implicit loopback: http://localhost, http://127.0.0.1, http://[::1], and their Vite
dev-server variants (:5173). These are always allowed because they are bounded to the
local machine and the token gate is still enforced.
The extension owner configures CLIP_ALLOWED_ORIGINS=chrome-extension://<extension_id> to
permit requests from their installed extension.
When the Origin header is absent (non-browser curl/local automation), the check passes —
the token gate alone is sufficient for non-browser paths (there is no CSRF risk without a
browser context).
Decision logic:
if origin is None → allow (non-browser; token is the auth)
if origin in (CLIP_ALLOWED_ORIGINS ∪ loopback) → allow
else → 403
This check is enforced server-side on every request, independent of CORS middleware.
2.4 Body cap (closes S-5)¶
CLIP_MAX_BODY_BYTES (env, default 2 MB) caps the accepted body size. Two enforcement
points:
1. Check Content-Length header before deserialisation → 413 if exceeded.
2. Check the size of the already-deserialised JSON body (belt-and-braces) → 413.
The reference app used read_to_string with no limit. 2 MB covers any realistic web article.
2.5 Safe path: no caller-supplied base directory (closes S-2)¶
The caller supplies {url, title, markdown}. The server derives the filename from
title (never from a caller-supplied path):
filename = _clip_safe_filename(title, url) # sanitize: strip /\\:*?"<>|, clamp 180 chars
name = safe_source_name(filename) # existing ADR-0020 sanitizer
dst = resolve_under_sources(name) # containment check: must start with raw_sources_dir/
The resolve_under_sources() function uses Path.resolve() + prefix check (existing
ADR-0020 §2.2 primitive). A path that escapes vault/raw/sources/ raises 400 before any
write.
The caller never supplies a project_path or base directory. This closes S-2 by
removing the attack surface entirely.
2.6 Atomic write (I5)¶
The file is written via tempfile.mkstemp + Path.replace() (same-directory atomic
rename) so a crash mid-write cannot corrupt the vault.
2.7 Auto-ingest via existing pipeline (I1, no new ingest path)¶
After the atomic write, the existing watchdog observer sees the file appear in
vault/raw/sources/ and calls ingest_file() through the normal pipeline. No new ingest
logic. The watcher's mtime/SHA gate (ADR-0001) deduplicates re-clips of unchanged content
(same URL + same Markdown body → skipped status, no double-ingest).
2.8 Content note (S-6 partial)¶
The Markdown is written verbatim to raw/sources/. The ingest pipeline (Obsidian-valid
frontmatter, LLM analysis/generation into wiki/) is downstream of this write. The raw file
is human-readable and the Obsidian-valid YAML frontmatter is constructed by the server (not
caller-supplied). The caller supplies only url/title/markdown — field values are
escaped before embedding in YAML (no YAML injection via ").
Full HTML sanitization (rehype-sanitize style) is deferred: the Readability + Turndown pipeline in the extension already strips script/style/iframe before converting to Markdown, and the LLM ingest analysis is bounded (I7) and runs in a sandboxed provider — not a direct exec path for injected content.
3. Configuration¶
| Env var | Default | Description |
|---|---|---|
CLIP_ENABLED |
false |
Master gate — must be true to open the ingress |
CLIP_TOKEN |
(none) | SECRET bearer token (required; no configured token = always 401) |
CLIP_ALLOWED_ORIGINS |
"" (empty) |
Comma-separated extra origins (e.g. chrome-extension://abc) |
CLIP_MAX_BODY_BYTES |
2097152 (2 MB) |
Body cap (I7) |
4. Chrome MV3 Extension¶
Location: extension/ (repo root)
| File | Purpose |
|---|---|
manifest.json |
MV3 manifest: permissions activeTab, scripting, storage |
popup.html + popup.js |
Clip UI: extract via Readability+Turndown, POST to /clip |
options.html + options.js |
Settings: base URL, token, extension ID display |
vendor/Readability.js |
@mozilla/readability (Apache-2.0, vendored) |
vendor/turndown.js |
turndown UMD build (MIT, vendored) |
vendor/Readability-LICENSE.md |
Apache-2.0 licence for Readability |
vendor/turndown-LICENSE |
MIT licence for Turndown |
Vendoring approach: Both libraries installed via npm (registry.npmjs.org), then the
UMD/browser-compatible dist files copied to extension/vendor/. No bundler required — the
MV3 extension executes them via scripting.executeScript(target, files=[...]) in the page
context. Licences preserved in vendor/.
Extension flow:
1. User clicks the toolbar icon → popup opens.
2. Popup injects vendor/Readability.js + vendor/turndown.js into the active tab via
scripting.executeScript.
3. An extraction script runs in the page: new Readability(docClone).parse() →
new TurndownService().turndown(article.content).
4. Title (editable) is shown. User clicks "Clip to Synapse".
5. Popup posts {url, title, markdown} to {baseURL}/clip with
Authorization: Bearer {token} header.
6. Chrome sets the Origin: chrome-extension://<id> header automatically on the cross-origin
fetch — the server validates it against CLIP_ALLOWED_ORIGINS.
First-run setup:
1. Install the extension (developer mode: load unpacked from extension/).
2. Open the extension Options page (right-click icon → Options).
3. Set Synapse Base URL (e.g. http://localhost:8000).
4. Generate a strong random token and set it as CLIP_TOKEN in Synapse env; paste it here.
5. Copy the Extension ID shown on the Options page.
6. Add CLIP_ALLOWED_ORIGINS=chrome-extension://<that-id> to Synapse env.
7. Set CLIP_ENABLED=true in Synapse env.
5. REST endpoint contract¶
POST /clip
Authorization: Bearer <CLIP_TOKEN>
Content-Type: application/json
Origin: chrome-extension://<extension_id> (set by Chrome automatically)
{
"url": "https://...",
"title": "Article Title",
"markdown": "# Article Title\n\nBody...",
"source": null // optional; defaults to url
}
→ 202 { "file_path": "raw/sources/Article-Title.md", "status": "queued", "overwritten": false }
→ 401 missing/invalid token
→ 403 origin not in allowlist
→ 413 body exceeds CLIP_MAX_BODY_BYTES
→ 400 unsafe/traversal filename
→ 503 CLIP_ENABLED=false
6. Security audit checklist (closes llm_wiki S-1..S-6)¶
| Audit finding | Synapse answer | Test |
|---|---|---|
| S-1 unauthenticated | CLIP_TOKEN bearer, constant-time compare, fail-closed | TC-CLIP-01, TC-CLIP-02 |
| S-2 attacker-controlled path | Server derives filename from title; safe_source_name + resolve_under_sources containment check | TC-CLIP-05 |
| S-3 drive-by cross-origin | Origin header validated server-side BEFORE processing; not CORS-only | TC-CLIP-03 |
| S-4 LAN exposure unauthenticated | Single service (no second server); CLIP_TOKEN always required | Architecture (no separate port) |
| S-5 no body cap | CLIP_MAX_BODY_BYTES gate (Content-Length + accumulated bytes) | TC-CLIP-04 |
| S-6 content not sanitised | Readability+Turndown strip scripts; YAML fields escaped; raw stored, not exec'd | TC-CLIP-06 (frontmatter check) |
7. Do-NOT list¶
- DO NOT bind a second HTTP server for clip ingress (closes S-4 permanently).
- DO NOT accept a
project_pathor base directory from the caller (closes S-2). - DO NOT rely on CORS alone to block cross-origin writes (closes S-3).
- DO NOT log the CLIP_TOKEN plaintext or hash — not even a prefix.
- DO NOT accept clip requests when
CLIP_ENABLED=false(master gate — 503 first). - DO NOT write to
vault/wiki/directly from the clip path (K1 layer separation; I5). - DO NOT set
CLIP_ALLOWED_ORIGINS=*in production — always explicit allowlist. - DO NOT use a short token (< 32 chars recommended) — enforce this in documentation.
- DO NOT bypass the watcher (call
ingest_filedirectly) — let the watchdog observe the file write (I1 — the mtime/SHA gate handles idempotency). - DO NOT add Tavily or any alternative search for deep-research of clipped pages (I9 — existing SearXNG pipeline handles follow-up research).