fix: 写路径安全加固(D1 token 泄漏 / D2 遥测丢写 / D3 举报滥用 / D4 kind 未持久化) - #17
Merged
Conversation
…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
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
按
.kiro/specs/write-path-security-hardening/规格实施,修复 note 创建/编辑写路径上的四个相关缺陷。151 测试全部通过,tsc --noEmit干净。四个缺陷
EditNotePage注释声称 token 不进服务端日志,但save()发的是GET /?edit=<token>&c=...,而 honologger()记录 path 加 queryX-Edit-Token头 + POST body;redactLogLine包住两个 logger 实例recordReject全部没有await也没有ctx.waitUntil,fetch(req, env)连ctx都没接 → 拦截统计与自适应限流静默丢数据fetch(req, env, ctx)+Background门面 +withBackground单一收口reporterGroup规范化后截断;阈值 10 个不同地址段;接入已实现但零调用的verifyTurnstile;365 天硬禁用 → 有界可撤销隔离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 覆盖。实施中推翻的四个文档假设
这些是规格写对了方向但细节与现实不符的地方,都在代码/测试注释里记录了:
bad_scheme这个 gate 在公开 API 上不可达 ——hasDangerousScheme只在isUrl()为真时被查询,但javascript:/data:串都过不了URL_NO_SCHEME_RE。设计文档指定用它触发拒绝,实际永远触发不了;改用shortener_blocked/brand_blocked。SELF.fetch派发到独立 isolate,测试端 console spy 看不到 Worker 日志;且 hono 把console.log绑成默认参数,模块求值时即固定。按文档原样写,D1 的日志断言会永远空转通过 —— 这正是非空转守卫(必须同时断言出现edit=[redacted])暴露出来的。ctx.waitUntil让写入发生在响应之后(这正是 2.8 要求的),所以"响应后立即读到计数"永远不成立。改为经waitOnExecutionContext确定性 drain,断言强度不变(仍是精确 +1,无轮询无 sleep)。verifyTurnstile的 fail-open 分支在沙箱不可达:我以为无外网会走 catch,实际拿到的是响应而非异常,j.success为假 → 403 拒绝。测试改为断言实际可观测行为并记录该分支不可达。回归保护
test/api.test.ts全程未修改,其中go=1不可绕过与白名单 302 两个检查是承重的resultPage快照字节一致(新kind参数可选且置尾)—— 一旦它们变动就说明有意外改动EditNotePage、InterstitialPage)先读 diff 后重新生成,逐项核对:token 只作为请求头值出现、/?edit=从快照文件彻底消失、无 key 的中间页完全不含cf-turnstile字样、无 CSP 变动明确排除的范围
内联 JS 搬到 ASSETS / 移除 CSP
unsafe-inline;ip="0"限流桶回落;recordReject自增原子化;格式化src/admin.ts;tsconfig strict;llms-full.txt两处既有漂移(401vs 实际403、editUrl缺#t=)。一处需要部署配合
wrangler.toml新增[vars] TURNSTILE_SITEKEY = ""。留空时行为与现在完全一致(不渲染组件、校验为空操作)。要真正启用人机校验需要:设置该 site key,并wrangler secret put TURNSTILE_SECRET。