Skip to content

fix(http): reject HEAD on write-shaped GET routes - #135

Merged
sv merged 3 commits into
flop-labs:mainfrom
rioyu123:codex/reject-head-writes
Aug 26, 2026
Merged

sv merged 3 commits into
flop-labs:mainfrom
rioyu123:codex/reject-head-writes

Conversation

@rioyu123

@rioyu123 rioyu123 commented Aug 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • stop Starlette's implicit HEAD reaching the four GET-shaped mutation routes
  • keep HEAD on the 21 ordinary read-shaped GET routes
  • cover all four write lanes, both signed lanes' replay counters, and the Allow contract

Closes #257

Why

Starlette automatically adds HEAD to function routes registered for GET. On this service that meant a metadata-only request could append a message, write a note, or spend a signed nonce while discarding the response body. The documented GET write flow is unchanged; only the previously implicit HEAD behavior now returns 405 with Allow: GET.

_get_write() builds the route and drops HEAD after init — Route.__init__ force-adds it to any GET, including when methods=["GET"] is passed (routing.py:237). matches() reads that set, so a HEAD misses the route before the endpoint runs rather than executing and being stripped of its body afterwards, and allowed_methods() reads it too, so the 405's Allow stays correct with no further change.

It is a 4-line factory beside the route table rather than a Route subclass in its own module: the subclass bought nothing (nothing does isinstance), cost 2 more core lines once the import and __init__/super() scaffold are counted, and added a 5-line file to CORE_FILES.

No public surface or abuse surface is added.

Two replay counters, both covered

The service has two, and only one is derived from the room's own records:

  • Messages (/r/<room>/say-signed/…) replay against _last_nonce, scanned back off the room's records. A refused HEAD appends nothing, so nothing raises that floor — pinned with a count assertion.
  • Ownership notes (/kv/<ns>/<key>/set-signed/…) replay against /kv/room-nonce/<room>, a server-written counter shared by every signer. Before the fix a HEAD probe moved it: verified locally that the counter went 1 → 2 on a HEAD, which is third-party-visible and strands the real writer's captured URL. This is the case @Jr-kenny asked to pin in #135 (comment); the test now asserts the counter is untouched by the refused HEAD and still spends on the following GET.

Test coverage of the changed paths

Exactly four routes change behaviour, and each is asserted twice — a HEAD probe (405, Allow: GET, and the write did not land) and the Allow contract test. Confirmed by un-fixing each route in turn: every one fails exactly two tests.

route HEAD refused Allow: GET side effect asserted absent
/r/{room}/say/… ✅ ✅ room count 0
/r/{room}/say-signed/… ✅ ✅ room count 0, nonce still spendable
/kv/{ns}/{key}/set/… ✅ ✅ note 404
/kv/{ns}/{key}/set-signed/… ✅ ✅ /kv/room-nonce/<room> unmoved

Read-shaped GETs keeping HEAD is asserted too, and the GET write lanes are unchanged and still covered by the rest of the suite.

Size caps

core/app.py 976 → 979 for the factory, and core_total 2027 → 2030, keeping the cap equal to the sum of the per-file caps. No new core file. The earlier MAX_HEADERS/MAX_HEADER_BYTES line-collapse has been reverted — it was an unrelated change made to fit under the old cap.

Proposed release note

Per #259 this PR does not edit CHANGELOG.md. Suggested wording for [Unreleased] → ### Fixed:

  • HEAD no longer executes the four GET-shaped write routes. Starlette adds HEAD to
    every GET route automatically; a metadata probe could therefore append a message, write
    a note or spend a signed nonce while discarding the response body. Read-shaped GETs keep
    HEAD, while write-shaped ones now answer 405 with Allow: GET.

Compatibility

No route, response shape or default moves. The only behaviour change a caller can observe is HEAD on the four write paths: previously it performed the write and returned 200 with no body, now it returns 405 with Allow: GET. HEAD is undocumented on those paths in /openapi.json, README.md, SKILL.md and the manual, so no documentation becomes inaccurate; the release note is proposed above rather than committed.

Validation

Rebased onto main at 31eabb8 (#259).

  • uv run ruff check . — passed
  • uv run ruff format --check . — 55 files already formatted
  • uv run ty check — passed
  • uv run coverage run -m pytest tests -q — 393 passed
  • uv run coverage report — 97.51% (app.py 98.87%; the factory is fully covered)
  • uv run sz.py --check — ok, core code-lines 2028 <= baseline
  • uv run sz.py --caps — app 979/979, total 2028/2030
  • contract check (the exact ci.yml invocation, incl. unsupported_method and allow_header_conformance) — 876 generated, 876 passed, no issues

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f4b087c455

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/write_route.py Outdated
@rioyu123

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 👍

Reviewed commit: 48bee45631

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Jr-kenny

Copy link
Copy Markdown

Independent repro on current main (e6114b3, 0.9.3), still live today: HEAD executes all four GET-shaped write handlers, PUT/DELETE/PATCH correctly 405, and OPTIONS advertises Allow: GET, HEAD, POST on those paths while the fix is out.

Two impact details beyond the summary here, both confirmed against a running instance:

  1. HEAD to a not-yet-existing room brings the room into existence: it spends the IP's room-creation token and _log_event writes a genuine created <room> line into /r/events. So infrastructure that only probes URLs, a link checker or crawler walking say-shaped URLs, can mint discovery-log entries and burn creation budget without ever sending a body-bearing request.

  2. The message lands with the nick from the URL, so the probe writes an attributed message in a chosen room as a side effect of a "safe" method. Same shape for notes: a HEAD to a set URL overwrites the note value.

The WriteRoute gate plus the Allow: GET change covers all of it as far as I can tell from the diff. One thing worth pinning in the tests if it is not already: the signed lane under HEAD must leave /kv/room-nonce untouched (no half-burnt counter when the gate refuses).

@sv
sv force-pushed the codex/reject-head-writes branch 3 times, most recently from 8da640d to 5a3ae91 Compare August 26, 2026 06:32
User and others added 3 commits August 26, 2026 14:35
Rebase onto main (flop-labs#259) and address review.

- Keep the HEAD strip inline in app.py as `_get_write()` rather than a
  `WriteRoute` subclass in its own core file. Starlette adds HEAD to any GET,
  including when `methods=["GET"]` is passed (routing.py:237), so it has to be
  dropped after init either way — but a 4-line factory beside the route table
  is 2 core lines cheaper than a module, needs no import, and does not add a
  5-line file to CORE_FILES. `core/app.py` goes 976 -> 979 and `core_total`
  follows, keeping the cap equal to the sum of the per-file caps.
- Drop the `assert self.methods is not None` guard with it: `assert` is stripped
  under `python -O`, which is the wrong failure mode for a route-level gate.
- Restore `MAX_HEADERS` / `MAX_HEADER_BYTES` as two lines. Collapsing them was an
  unrelated change made to fit under the old cap; CONTRIBUTING rejects code golf.
- Cover the ownership lane's shared counter, as asked in review: a HEAD probe on
  `set-signed` moved `/kv/room-nonce/<room>` before the fix, which is visible to
  third parties and strands the real writer's captured URL. The message lane
  replays off the room's own records, so a count assertion pins that one.
- Assert `Allow: GET` on all four write lanes, not two. Verified by un-fixing
  each route in turn: every one fails exactly two tests.
- Drop the CHANGELOG.md edit. flop-labs#259 moved changelog assembly to merge/release
  time — the proposed release note is in the pull request body instead.

Closes flop-labs#257

Co-Authored-By: Claude Opus 5 <[email protected]>
@sv
sv force-pushed the codex/reject-head-writes branch from 5a3ae91 to 6e26b72 Compare August 26, 2026 06:37
@sv
sv merged commit b6f90f1 into flop-labs:main Aug 26, 2026
3 checks passed
japaneseml added a commit to japaneseml/technocore-chat that referenced this pull request Aug 27, 2026
_condition() returned on the if_absent branch before if= was ever read, so a
write sent with both (?if=X&if_absent=1) silently ignored if=. When if= was
the condition that could not hold, the request still answered 200 instead of
409 or a refusal -- a caller relying on if= for compare-and-set (task-state
transitions per interop.md) could not tell its condition was never checked.

The two forms are mutually exclusive by definition (if_absent: nothing is
there; if=: something specific is there), so there is no correct value to
pick silently. Both are now read, and sending both together raises
StoreError, mapped to the existing 400 handler with a body naming the two
colliding parameters -- matching the shape of every other input refusal
rather than introducing a new exception type for one call site.

core/app.py grows 981 -> 984 code lines for the extra branch and raise;
sz-baseline.json's baseline and cap move up by the same 3 lines, following
the precedent set by flop-labs#135.

Regression test in tests/http/test_notes.py reproduces both directions from
the issue (if= unsatisfiable + if_absent satisfiable, and the reverse) plus
the POST lane, and fails against the pre-fix code (200 instead of 400).

Fixes flop-labs#290

Co-Authored-By: Claude Fable 5 <[email protected]>
Jr-kenny pushed a commit to Jr-kenny/technocore-chat that referenced this pull request Sep 9, 2026
flop-labs#135 pinned the room half of the dual-lane paths; this pins /kv/<ns>/<key>:
a HEAD there must answer from the reader (200 on a stored note, 404 on an
absent one) rather than fall through to the POST writer. Requested in the
close-out of flop-labs#258.
Jr-kenny pushed a commit to Jr-kenny/technocore-chat that referenced this pull request Sep 21, 2026
flop-labs#135 pinned the room half of the dual-lane paths; this pins /kv/<ns>/<key>:
a HEAD there must answer from the reader (200 on a stored note, 404 on an
absent one) rather than fall through to the POST writer. Requested in the
close-out of flop-labs#258.
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.

HEAD requests execute the write: a metadata request from any monitor or prefetcher creates rooms and notes

3 participants