Skip to content

fix: 写路径安全加固(D1 token 泄漏 / D2 遥测丢写 / D3 举报滥用 / D4 kind 未持久化) - #17

Merged
catoncat merged 17 commits into
mainfrom
fix/write-path-security-hardening
Aug 17, 2026
Merged

fix: 写路径安全加固(D1 token 泄漏 / D2 遥测丢写 / D3 举报滥用 / D4 kind 未持久化)#17
catoncat merged 17 commits into
mainfrom
fix/write-path-security-hardening

Conversation

@catoncat

Copy link
Copy Markdown
Owner

.kiro/specs/write-path-security-hardening/ 规格实施,修复 note 创建/编辑写路径上的四个相关缺陷。151 测试全部通过,tsc --noEmit 干净。

四个缺陷

缺陷 修复
D1 EditNotePage 注释声称 token 不进服务端日志,但 save() 发的是 GET /?edit=<token>&c=...,而 hono logger() 记录 path 加 query token 走 X-Edit-Token 头 + POST body;redactLogLine 包住两个 logger 实例
D2 11 处 recordReject 全部没有 await 也没有 ctx.waitUntilfetch(req, env)ctx 都没接 → 拦截统计与自适应限流静默丢数据 fetch(req, env, ctx) + Background 门面 + withBackground 单一收口
D3 举报无人机校验、阈值 3、且 dedupe 的 IPv6 截断对压缩形式失效 → 单个 /64 可永久禁用任意链接 365 天 reporterGroup 规范化后截断;阈值 10 个不同地址段;接入已实现但零调用的 verifyTurnstile;365 天硬禁用 → 有界可撤销隔离
D4 meta 无 k 字段,"302 跳转 vs 渲染"这个安全相关分支每次读取靠 URL_NO_SCHEME_RE 重新推导,五处重复 写入时持久化 k,读取时只信它;五处推导收敛为 resolveKind + readKind

实施顺序与证据链

任务 1 先行且不可跳过:F 与 F′ 无法同时载入同一个 Worker isolate,所以"修复前后行为一致"只能靠先在未修复的代码上录制观测基线(130 条语料)再比对。源码一旦改动,这份证据不可恢复。该 fixture 在每个缺陷修完后都重新断言,全程零 diff。

任务 2 8 个探索性用例在未修复代码上全部失败 —— 四个根因逐一确认,无一被推翻。修复后 8 个全绿,测试文件本身未改断言强度

顺序 D2 → D4 → D1+D3:D2 不动 HTML 因此零快照变动,并提前落地类型标注;D1 与 D3 配对收尾,两者都改内联 JS,一次快照 review 覆盖。

实施中推翻的四个文档假设

这些是规格写对了方向但细节与现实不符的地方,都在代码/测试注释里记录了:

  1. bad_scheme 这个 gate 在公开 API 上不可达 —— hasDangerousScheme 只在 isUrl() 为真时被查询,但 javascript:/data: 串都过不了 URL_NO_SCHEME_RE。设计文档指定用它触发拒绝,实际永远触发不了;改用 shortener_blocked / brand_blocked
  2. SELF.fetch 派发到独立 isolate,测试端 console spy 看不到 Worker 日志;且 hono 把 console.log 绑成默认参数,模块求值时即固定。按文档原样写,D1 的日志断言会永远空转通过 —— 这正是非空转守卫(必须同时断言出现 edit=[redacted])暴露出来的。
  3. 探索性 case 3 的断言与需求 2.8 自相矛盾ctx.waitUntil 让写入发生在响应之后(这正是 2.8 要求的),所以"响应后立即读到计数"永远不成立。改为经 waitOnExecutionContext 确定性 drain,断言强度不变(仍是精确 +1,无轮询无 sleep)。
  4. verifyTurnstile 的 fail-open 分支在沙箱不可达:我以为无外网会走 catch,实际拿到的是响应而非异常,j.success 为假 → 403 拒绝。测试改为断言实际可观测行为并记录该分支不可达。

回归保护

  • test/api.test.ts 全程未修改,其中 go=1 不可绕过与白名单 302 两个检查是承重的
  • 三个 resultPage 快照字节一致(新 kind 参数可选且置尾)—— 一旦它们变动就说明有意外改动
  • 两个变动的快照(EditNotePageInterstitialPage先读 diff 后重新生成,逐项核对:token 只作为请求头值出现、/?edit= 从快照文件彻底消失、无 key 的中间页完全不含 cf-turnstile 字样、无 CSP 变动
  • 新增带 siteKey 的快照用例钉住 Turnstile 组件标记

明确排除的范围

内联 JS 搬到 ASSETS / 移除 CSP unsafe-inlineip="0" 限流桶回落;recordReject 自增原子化;格式化 src/admin.ts;tsconfig strict;llms-full.txt 两处既有漂移(401 vs 实际 403editUrl#t=)。

一处需要部署配合

wrangler.toml 新增 [vars] TURNSTILE_SITEKEY = ""留空时行为与现在完全一致(不渲染组件、校验为空操作)。要真正启用人机校验需要:设置该 site key,并 wrangler secret put TURNSTILE_SECRET

…d code

F (current code) and F' (fixed code) cannot both be loaded in one Worker
isolate, so `ASSERT F(X) = F'(X)` cannot be evaluated live. Capture
observational equality as a recorded expectation fixture instead: run a
130-entry request corpus against the CURRENT UNFIXED tree, snapshot the
observable tuple, and commit it before any file under src/ moves.

- add fast-check as a devDependency (pinned seed + numRuns keep the
  generated half of the corpus reproducible)
- test/helpers.ts: captureLogs(), expectRedactedLogPresent() (the
  non-vacuity guard), nextIp() (distinct cf-connecting-ip per request,
  since rateLimit() falls back to ip="0"), observe() (the tuple from
  design.md: status, JSON error code, x-* headers, location, /raw bytes,
  n:/m: KV state excluding meta.k)
- test/helpers.test.ts: harness self-checks, incl. the guard failing on
  an empty transcript
- test/preservation.test.ts: the corpus (read path, body-token edits,
  error envelopes, create success, admin, CORS/assets/abuse), recorded to
  test/__fixtures__/preservation-baseline.json
- vitest.config.ts: PRESERVE=record rewrites the fixture and narrows the
  run to that suite; default is assert mode so CI cannot silently
  re-record over a regression

Normalized/excluded fields and the gates that are unreachable in the test
environment are documented inside the fixture itself.

No file under src/ is touched.

Refs: task 1, requirements 3.1-3.15
Eight cases encoding the FINAL expected behavior of D1-D4. All eight FAIL on
the current tree; the failures are the counterexamples that confirm the
root-cause analysis. Tasks 4.5 / 5.7 / 6.12 re-run this file unchanged as the
fix check, so plain `it` is used rather than `it.fails` (which inverts and
would have to be edited back at fix time).

Two documented deviations from design.md, both forced by direct observation:

- Cases 3/4 use `shortener_blocked` instead of `bad_scheme`. `bad_scheme` is
  unreachable through the public API for any input, because hasDangerousScheme()
  is only consulted when isUrl() is true. This changes which gate fires, not
  D2's root cause (the missing ctx.waitUntil).
- Case 1 cannot use SELF.fetch. SELF runs the Worker in a separate isolate, and
  hono's logger(fn = console.log) binds console.log as a default parameter at
  module-evaluation time, so a test-side spy is invisible twice over. Adds
  workerWithLogCapture() to test/helpers.ts: a second, freshly-evaluated Worker
  entry instance behind a console.log trampoline, sharing env with SELF.

No file under src/ is touched. Suite: 42 tests, 34 passing (unchanged),
8 intentionally failing. `tsc --noEmit` clean.
…task 3)

One module owns every generated input domain so tasks 4–6 consume a reviewed
generator instead of redefining the domain inline:

  arbToken / arbTokenMutation   Property 3  (no forged token authenticates)
  arbQueryString                Property 1  (redactLogLine)
  arbContent / arbTtl           Property 2  (transport equivalence)
  arbAddressForms + pair gens   Property 8  (reporterGroup)
  arbBoundaryContent            Properties 11, 12, 14 (persisted kind)
  arbName / nextName            every property that has to create a note

Bounds come from the real code (URL_MAX / TEXT_MAX, NAME_RE, RESERVED,
TTL_OPTIONS, isUrl / isBlockedTargetHost), so generation never wanders onto an
error path — a generated 413 / malformed_url / shortener_blocked / name_taken
would fail a property for a reason unrelated to the property.

Seed is pinned suite-wide (20260817) so a counterexample reproduces; numRuns is
tiered by I/O cost (300 pure / 25 forged-token / 15 worker / 6 heavy) against a
measured ~10ms per KV round trip.

Properties 4, 5, 7, 9, 10, 13 and 15 stay example-based per design.md — they
assert on ordered multi-request KV-counter sequences and would fight the
non-atomic increment in recordReject. A file comment says so, so a later task
does not "helpfully" convert them.

test/arbitraries.test.ts self-checks the non-obvious generators: token mutants
always differ from the original (and each kind does what it says), same-range
address pairs really share a /64 or /24 while different-range pairs do not —
verified against an independent text→model reference parser, never against the
yet-to-be-written reporterGroup — plus creatability, boundary straddling, and
name uniqueness. A degenerate generator would make later properties pass
vacuously.

No file under src/ is touched.
…k 4.1)

Adds makeBackground(exeCtx) -> { waitUntil, settle } and the exported
Background type in src/util.ts. waitUntil forwards to the platform's
ExecutionContext when one was supplied; otherwise the promise is queued
and awaited by settle(), which is what makes rejection accounting
deterministic in unit-style tests that call handlers directly. Every
queued promise is wrapped so a failed telemetry write can never reject
outward or surface as an unhandled rejection.

Additive only: no call site, handler signature, or fetch signature
changes yet (tasks 4.2 / 4.3). Unit tests cover both modes and the
rejecting-promise path in each.
…choke point (task 4.2)

The exported fetch was written as (req, env), so ctx never reached hono and
c.executionCtx was unusable. Annotate ctx: ExecutionContext (the only
compiler-level check available under strict: false) and forward it to both
baseApp.fetch and subApp.fetch.

Add safeExecutionCtx(c), which guards the getter with try/catch because hono
THROWS "This context has no ExecutionContext" rather than returning undefined,
and withBackground(c, run) — the single choke point that builds the Background
facade and awaits bg.settle() in a finally, so queued telemetry settles even
when a handler throws into onError.

Wire the three write routes through it: POST/PUT / on the apex, the GET /
branch that forwards to handleCreate when ?c= is present, and subApp.all("*").

No behavior change: handler signatures and the 11 recordReject call sites are
untouched (task 4.3). Suite unchanged at 66 passing + the 8 intentional
exploratory failures; tsc --noEmit clean.
…writes alive (task 4.3)

handleCreate/handleEdit/handleSubdomain take a trailing `bg: Background`
(handleSubdomain forwards it to handleEdit), and the three withBackground
route closures pass it through. The parameter is annotated deliberately:
under `strict: false` it is the only compiler-level check that a call site
did not forget it.

All 11 recordReject() calls become bg.waitUntil(recordReject(...)):
6 in handleCreate (brand_blocked, bad_scheme x2, shortener_blocked,
unsafe_target, content_blocked) and the same 5 minus brand_blocked in
handleEdit. Gate order, gate codes and statuses are untouched.

Exploratory case 4 — the authoritative D2 signal, five rejections must
engage the adaptive 2/min cap — now passes. recordReject's internal
read-modify-write stays non-atomic (pre-existing KV limitation, out of
scope).
…(task 4.4)

Fix-checking tests for D2, plus one user-approved repair to exploratory case 3.

Property 4 — rejection accounting is exact. Parametrized sequences of 1, 3 and
5 rejections mixing the two reachable gates, one fresh cf-connecting-ip per
sequence, requests issued sequentially (recordReject's read-modify-write is
non-atomic). Asserts the per-code delta over all five REJECT_CODES, that the
total advanced by exactly n, and that rej-ip:<ip> equals n. Each request's
observed code is asserted too, so a gate rejection silently becoming a 429
cannot mask the counts.

Property 5 — the adaptive cap engages. 5 rejections then 4 valid creates from
one address: at most ADAPTIVE_RATE_LIMIT (2) non-429, and every 429 keeps
details.limit and details.windowSeconds: 60.

Property 6 — telemetry never blocks or breaks the response. Table-driven over
the gate codes against absolute expected values (F and F' cannot both be loaded
in one isolate, so no live comparison is possible), plus a deterministic
"never breaks" check that hands the Worker a KV binding whose put throws for
rej: / rej-ip: keys and asserts the response is byte-identical. Only
brand_blocked and shortener_blocked are reachable through the public API here;
bad_scheme, unsafe_target and content_blocked are listed with their reasons and
each reason is re-verified by a test, so the gap is visible rather than implied.

Property 11 is confirmed indirectly (2.11): /admin/stats' day row advances by
exactly the rejections issued.

Case 3 repair — MECHANISM only, strength unchanged. Post-4.3 the telemetry
write lands after the response, which is what 2.8 requires, so reading the
counter straight after SELF.fetch asserted a timing guarantee 2.8 forbids
(observed: before=0 immediate=0 delayed=1). It now uses the direct-worker path
with createExecutionContext + waitOnExecutionContext, whose drain is
deterministic. Still "advanced by EXACTLY 1" and "rej-ip:<ip> EXACTLY 1" — no
>= 1, no retry loop, no sleep. The stale "INCONCLUSIVE ON PASS" label is gone.

helpers.ts gains fetchDrained() for that pattern, and the log trampoline is
fixed: it compared console.log against itself to decide where to forward, but
node's console.log accessor returns a fresh bound wrapper, so with no spy
installed it recursed until the stack blew. Case 1 never hit it (it always logs
inside a capture window); the new direct-worker calls do not.

Tests only — nothing under src/ is touched. 87 tests: 81 pass, 6 fail, and the
6 are exactly the non-D2 exploratory cases (1, 2 = D1; 5, 6 = D3; 7, 8 = D4).
The preservation fixture asserts clean and no snapshot moved.
…s (task 5.1)

D4's root cause is that url-vs-text was re-derived from the content at five
independent call sites (handleCreate, handleEdit, handleSubdomain,
loadNoteDetail, ResultPage), so the same note could be classified differently
depending on who asked and when. This lands the two helpers those sites will
collapse onto:

  resolveKind(content)     -> isUrl(content) ? "url" : "text"   (write time)
  readKind(meta, content)  -> meta.k when it is exactly "url" or "text",
                              else resolveKind(content)          (read time)

readKind guards on the two exact literals rather than on truthiness, so a
corrupt or unknown k ("URL", 1, null, {}) degrades to today's derivation
instead of selecting an undefined branch. meta is probed defensively because
callers pass whatever JSON.parse returned, including null.

No call site is wired up yet (tasks 5.2-5.5), so behavior is unchanged: the
six exploratory failures stay at six, including D4 cases 7 and 8, which cannot
pass until something persists k.

Unit tests cover k valid (and beating the content), k corrupt in every shape
design.md names, k absent, meta null/undefined, meta not an object, and the
equality that carries the 2.21 legacy guarantee — the fallback equals
isUrl(content) exactly — asserted over a table and over generated
arbBoundaryContent, which straddles URL_NO_SCHEME_RE.

Tests: 97 total across 10 files, 91 passing, 6 failing (the unchanged
exploratory set). tsc --noEmit clean.
handleCreate derived the url-vs-text decision inline with isUrl(rawContent)
and then re-derived the same decision a second time for the JSON `kind` and
the `x-kind` header, while `m:<name>` recorded nothing at all. A note's kind
was therefore a pure function of a heuristic regex evaluated at read time,
with no stable owner contract (bugfix.md 1.18).

resolveKind(rawContent) is now the single write-time authority: urlMode, the
length limit chosen, the gates run, the persisted `k`, the response `kind`
and the `x-kind` header all derive from it, so the branch taken and the
branch recorded cannot disagree (2.19, 2.24).

meta becomes { v: 1, h, t, ct, k }. `v` deliberately stays 1 — nothing in
the codebase reads meta.v, and the feature detection 2.21 needs is presence
of `k`, so a bump would add a version predicate with no reader.

Scoped to handleCreate; handleEdit (5.3), handleSubdomain (5.4), admin.ts
and ResultPage.tsx (5.5) still hold their own derivations.

Exploratory case 7 flips green; case 8 now fails on its post-edit assertion
only (5.3's job). Preservation asserts clean in default mode: observe()'s
recorded m:<name> tuple excludes `k` by design (test/helpers.ts), so adding
it is not a regression.
handleEdit parsed the meta record twice and re-derived the url-vs-text
decision from `isUrl()` on every request, so a note's kind could differ
between the write that stored it and any later read.

- parse `m:<name>` once: `origMeta` is what is stored, `meta` is the
  mutable copy that gets written back;
- content supplied => `kind = resolveKind(content)` and `meta.k = kind`,
  the only way a note's kind changes (2.23);
- no content supplied (ttl / renew only) => `kind = readKind(origMeta,
  existingContent)` and `meta.k` is left exactly as it was, including
  absent for legacy records (2.22). Deliberately NOT backfilled: the
  value would equal the legacy fallback anyway, but `k` would move on an
  operation that supplies no content, and the 7-day maximum TTL already
  bounds the fallback window;
- the meta rewrite guard gains `meta.k !== origMeta.k`, so a
  newly-recorded kind is not dropped by the "nothing changed, reuse the
  original bytes" path;
- `urlMode`, `target`, the JSON `kind` and the `x-kind` header all derive
  from that single `kind` value (2.24).

Exploratory case 8 (a text -> url content rewrite recorded in `k`) is now
green, including its read-branch assertion. Preservation fixture asserts
clean; typecheck clean.

Requirements: 2.22, 2.23, 2.24, 3.4
handleSubdomain now derives its branch from the persisted kind
(`readKind(meta, content)`) instead of re-running `isUrl(content)`, so the
redirect-vs-interstitial-vs-notePage decision structurally follows what was
recorded at write time rather than coincidentally agreeing with a regex (2.20).

Legacy records with no usable `k` fall through readKind to `isUrl(content)`,
which is exactly today's derivation, so their behavior is unchanged (2.21).

The `parseUrlSafe(content)` guard that degrades an unparseable "url" note to
`notePage` stays: kind selects the branch, the parse check remains a safety net.
`isAllowedTarget(target)` -> 302-vs-interstitial is untouched (3.1, 3.2, 3.3).

`isUrl` is no longer referenced in src/index.ts, so it is dropped from the
util import; the two remaining mentions are comments.
loadNoteDetail and ResultPage were the remaining two of five places that
re-derived url-vs-text from content. Both now consume the persisted kind.

- admin.ts loadNoteDetail: isUrl(content) -> readKind(meta, content)
- ResultPage: new optional trailing `kind` param; both production call
  sites in index.ts pass the value the writer persisted

The param is optional and trailing so test/views.test.ts's three
resultPage() calls compile unchanged and their snapshots stay
byte-identical. No isUrl() call site remains outside src/util.ts.

Mainline-Skip: mainline CLI unavailable in sandbox
…5.7)

Properties 11-15 from design.md:

- P11 kind persisted and authoritative: three example branches (302 on
  allowlist / interstitial / notePage) each asserting m.k == json kind ==
  x-kind == the branch observed, plus a PBT over arbBoundaryContent.
- P12 kind invariant across reads: PBT, 5 rounds x 4 surfaces (/, /raw,
  ?format=json, /admin/note), asserting no surface disagrees.
- P13 kind survives non-content ops: url note, text note, and a
  hand-seeded LEGACY record with no k. The legacy row is load-bearing:
  a renew rewrites the record (ct moves) and k must STILL be absent,
  which is what makes the rejected backfill alternative observable.
- P14 kind recomputed only on content rewrite: PBT over
  arbBoundaryRewrite, plus the exploratory case-8 text->url flip now
  asserted as recorded rather than silent.
- P15 legacy records unaffected: the only test that builds KV state by
  hand, because the fixed code cannot produce a k-less meta record.

5.7 verification: 110 tests / 106 passing; the 4 remaining failures are
exploratory cases 1, 2, 5, 6 (D1/D3, task 6). Preservation fixture
asserts clean, the api.test.ts go=1 and allowlist-302 checks pass
unmodified, and the resultPage snapshots are byte-identical.

Mainline-Skip: mainline CLI unavailable in sandbox
…s 6.1-6.10)

D1 — the owner's edit token no longer travels in a URL, and no log line
carries it in any transport.

  6.1 wrangler.toml gains `[vars] TURNSTILE_SITEKEY = ""` (public by
      design; TURNSTILE_SECRET stays a secret) and worker-configuration.d.ts
      is regenerated, so env.TURNSTILE_SITEKEY is typed before 6.10 reads it.
  6.2 util.ts: pure `redactLogLine(s)` over
      /([?&](?:edit|token|key|ts)=)[^&\s]*/gi. The parameter NAME is kept
      deliberately: the line still records that a token was presented, which
      is also what makes the redaction non-vacuously testable.
  6.3 index.ts: both logger() instances print through it. hono builds
      `path` as path PLUS query and hands the whole line to its print
      function, so this one seam covers the `<--` and `-->` lines of every
      route, including the deprecated GET form and /admin/*?key=.
  6.4 handleEdit token precedence becomes
      body || X-Edit-Token || ?edit= || "". Body FIRST, so every request
      shape that exists today resolves to the token it resolves to now and
      preservation is observed rather than argued. Routing is unchanged: a
      GET carrying only the header still falls through to a plain read.
  6.5 EditNotePage save() POSTs to "/" with the token in X-Edit-Token and
      the content in a JSON body. Status handling, the `#t=` fragment parse,
      the /raw seeding fetch and the Cmd/Ctrl-S binding are untouched. The
      header comment claimed the token never reaches server logs; this is
      the change that makes the claim true (2.6).

D3 — a report now costs a distinct address range and a solved challenge,
and the automatic action is bounded and reversible.

  6.6 constants.ts: ABUSE_AUTO_DISABLE (3) is removed, not aliased. Added
      ABUSE_AUTO_QUARANTINE (10), QUARANTINE_MAX/MIN_TTL_SEC and
      ABUSE_GROUP_TTL_SEC, with the threshold rationale recorded.
  6.7 util.ts: `reporterGroup(ip)` canonicalizes before truncating —
      /64 for IPv6, /24 for IPv4. The old chained split/slice was textual
      and so silently failed on compressed IPv6, which is the exact
      mechanism by which one actor presented as three reporters (1.11).
  6.8 responses.ts readBody keeps the challenge token; handleAbuseReport
      calls verifyTurnstile (until now zero call sites) BEFORE any read or
      write of the counter, so an unverified report is fully inert. The
      dedupe key is now keyed on the reporter group with the counter's TTL
      and no `:<day>:` segment, so "10 distinct ranges" means what it says.
  6.9 The 365-day hard disable becomes a quarantine whose TTL is the note's
      own remaining lifetime clamped into [1h, 7d]. `exp` is stored inside
      the payload because KV does not expose remaining TTL on read. `auto`
      markers gain the expiry and an appeal line; status 410 and code
      "disabled" are unchanged for both auto and admin markers.
  6.10 InterstitialPage takes an optional siteKey and renders the Turnstile
      widget when configured; the report control forwards the solved token.
      No CSP change — challenges.cloudflare.com is already allowed.

Tests: unit + property coverage for redactLogLine (Property 1) and
reporterGroup (Property 8), and the quarantine TTL clamp, in
test/util.test.ts. Exploratory cases 1, 2, 5 and 6 now pass unedited;
the Property 16 preservation fixture still asserts clean. The two view
snapshots (editNotePage, interstitialPage) are left FAILING on purpose —
regenerating them is task 7's human-reviewed step.
…12, 7)

test/security.test.ts — the Worker-level half of D1/D3 (Properties 1 and 8
are unit-level and live in test/util.test.ts):

- P2 transport equivalence: PBT over (content, ttl, renew) — header, body
  and deprecated ?edit= produce the same status and the same stored bytes.
  Plus: a GET carrying only X-Edit-Token stays a plain read (2.3, and what
  F did), and no transport leaks the token into a log line, with the
  non-vacuity guard on the deprecated form.
- P3 no forged token: PBT over token mutations x 3 transports.
- P7 sub-threshold: n in {1,5,9} distinct /64s never disable.
- P9 unverified reports inert, both env shapes, incl. that a rejected
  report does not burn the group's dedupe slot.
- P10 quarantine bounded (payload exp - at <= 7d), surfaces its expiry in
  both JSON and HTML, and is cleared by /admin/enable. Plus: one /64
  can never reach the threshold however many requests it sends.

One assumption of mine was wrong and is now documented in place: I
expected the sandbox's lack of outbound network to exercise
verifyTurnstile's fail-OPEN catch branch. It does not — the request
returns a RESPONSE rather than throwing, so j.success is falsy and the
report is rejected 403. The test asserts the observable behavior and
records that the fail-open path is unreachable from here.

Snapshot review (task 7), read before regenerating:
- editNotePage: token appears ONLY as the x-edit-token header value; the
  old `var u="/?edit="+encodeURIComponent(token)` is gone; `/?edit=` no
  longer occurs anywhere in the snapshot file.
- interstitialPage (no key): only diff is tsTok()/REPORT_JS; card markup,
  host/target, confirm dialog and rel="noopener noreferrer nofollow" are
  byte-identical, and the page contains no cf-turnstile string at all.
- New with-siteKey case pins the api.js script tag and the data-sitekey
  div; cf-turnstile occurs exactly once in the file, inside that snapshot.
- The three resultPage snapshots did not move.
- No CSP line in the diff.

151 tests passing, typecheck clean, preservation fixture asserts clean.

Mainline-Skip: mainline CLI unavailable in sandbox
…k 8)

Requirement 2.5 — the docs must match the implementation.

docs/API.md
- route table: POST <sub>.0g.hk/?edit=tk -> POST <sub>.0g.hk/ with the
  token in X-Edit-Token or the body
- new transport table (header / body / deprecated query) stating that all
  three validate identically, and why the first two are preferred
- the four ?edit= curl examples rewritten to the header form, with one
  deprecated example kept and labelled
- an explicit note that X-Edit-Token is honoured on POST/PUT only and
  that a GET carrying it is a plain read
- the 浏览器兼容 entry annotated as deprecated-but-supported and redacted
- new 举报与自动隔离 section: reporter groups (/64, /24), the Turnstile
  requirement and its no-op when unconfigured, the 10-distinct-group
  threshold, the bounded [1h, 7d] reversible quarantine, the 410 shape
  with details.auto/until, admin lift, and why there is no self-serve
  un-quarantine

public/llms.txt — the body form was already correct; added the
X-Edit-Token alternative and the deprecation note.

public/llms-full.txt — rewrote the stale "After ABUSE_AUTO_DISABLE (3)
reports the note is auto-disabled" line, and added the transport note.

Not fixed here, deliberately (design.md excludes them): llms-full.txt
says 401 invalid_token where the code returns 403, and shows editUrl
without the #t= fragment.

151 tests passing, typecheck clean, preservation fixture untouched.

Mainline-Skip: mainline CLI unavailable in sandbox
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@catoncat
catoncat merged commit e3e1d73 into main Aug 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants