Repository navigation
Fix 30 review findings: a WSGI header spoof, a SQLite crash after close, and 28 more - #615
Merged
Merged
Conversation
…5-0219) CGI's mapping turns `-` into `_` and leaves `_` alone, so `X_Forwarded_For` and `X-Forwarded-For` are two headers in the request and one key in the environ, HTTP_X_FORWARDED_FOR, where build_environ's later PyDict_SetItem wins. Behind a proxy that sets the dashed header and forwards underscore names, a client sending the underscore spelling after it replaced the address, scheme or identity header the proxy set. header_is_excluded now drops any name holding `_` beside `Proxy`, as gunicorn's header_map does by default since 22.0 and Django's runserver does. Dashed names pass, X-Forwarded-* included. build_environ is the one environ builder (every WSGI path, inline, pooled, threaded and the synthetic /ws/message POST, reaches it through WSGIApp.serve and PyBridge.run). The ASGI executor's scope (_build_scope, _py_headers) is untouched and still carries every name raw; the buffered ASGI path (--blocking-threads on an ASGI app) builds its scope from the environ, so it drops them too, where it used to hand the app the collapsed spoofed value under the dashed name. test_environ.mojo:test_underscore_header_is_excluded_from_the_environ (SPEC G29) walks a real header map as build_environ does; with the fix reverted it fails on the predicate, and with that assert skipped the environ holds 6.6.6.6 for 203.0.113.7. smoke-wsgi sends both spellings, the underscore one last, to bin/m0serve: with the fix reverted it fails with HTTP_X_FORWARDED_FOR='6.6.6.6'. AGENTS.md, RUNNING.md and the changelog say the same. Review finding W1 (2026-10-10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…head, the counter's reads are gated The shim's state["m0"].publish took m0_shared_fetch_add's previous value as its id, where m0pub, pg_listen, ws.mojo and the Mojo host all take that value plus one. On one counter the two Python publishers handed out the same id (m0pub 1, then the shim 1), and a hold that had the first frame dropped the second with nothing logged; a fresh server's first ASGI publish was 0, which no first-visit subscriber receives. The lambda now adds 1. smoke-asgi-fanout's probe requires a fresh server's first publish to be id 1 and an m0pub publish (a new /publish-m0pub route on the feed app) followed by a state["m0"] one to take two ids; against the unfixed shim the first fails with id=0 and, skipped, the second with [3, 3] across two workers. SPEC L12 says so. A WebSocket hold subscribed at the Last-Event-ID it presented, inline and from a pool thread's H frame alike, so an id ahead of the counter suppressed every frame until the counter passed it, where an SSE hold has been clamped since I33. Both now subscribe at _socket_last_id, the id clamped to _event_head(); nothing is replayed, and the comment that said one rule covered both transports says what is true. SPEC I12. The handler's two reads of the shared counter, the late worker's floor and _event_head, had no gate that would fail without them. test_hold_resume.mojo builds a --realtime WSGIHandler (an application registered in sys.modules) over a SharedAtomics page named by M0_SHARED_ID_ADDR: a resume at 30 under a counter at 40 is an m0-gap, one at 40 is not, and one at 43 with the counter moved to 45 is neither clamped nor a gap (covers I33). Two more tests hold the socket clamp, one per way a socket hold lands (covers I12). Each was proven against its fix reverted: the floor read removed, no gap frame; the counter read removed from _event_head, a gap at 43; either socket site unclamped, its test subscribed at 999999999. --doctor reported replay_frames as the flag's value without --realtime, where the handler keeps no journal; it reports 0 there now, and smoke-doctor holds both (it read 7 for 0 with the fix reverted). Review findings W2, W5, W3, W7 (2026-10-10). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… capped at 4096 (W6, W4) parse_size multiplied before checking, so 17179869184g wrapped to a 0-byte cap and 9999999999g to a negative that left the default standing unsaid. It now refuses digits > Int.MAX // multiplier: exit 2 as a flag, env_ignored as M0_MAX_BODY. A replay journal of any depth was accepted, though each loop keeps N frames of up to 64 KB and a full journal shifts its lists per publish. A depth over 4096 is now a 78 from m0_wsgi.checks (rule replay-frames, SPEC I43), so the server and --doctor agree, with or without --realtime, flag or env. Usage text and RUNNING.md state the cap and the per-loop memory. Findings W4 and W6. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… before C, a prune raises its own error (S1, S4, S5) close() leaves the handle 0, and in_transaction, last_insert_rowid, changes, total_changes, busy_timeout and register_array_module passed it to entry points that check for NULL only under SQLITE_ENABLE_API_ARMOR: Debian 12's libsqlite3 (deploy/mojo's base) dereferences it, SIGSEGV; Apple's answers 0, so in_transaction() read True after a close, and register_array_module leaked its module buffer. The four accessors now answer False/0 without calling SQLite; the two that change the connection raise "... on a closed connection" as create_function does (SPEC O29). Every other FFI call taking the db handle is guarded or reaches sqlite3_exec / sqlite3LockAndPrepare, which check it whatever the build; vtab.mojo's comment on create_module_v2's destructor names the armor's MISUSE as the one refusal that skips it. execute() and the Connection constructors refuse a NUL in the SQL or the path before the call, ending (rc=21): sqlite3_exec and sqlite3_open_v2 read C strings, so "DELETE FROM t\0 WHERE id = 5" deleted every row with rc 0 (checked against Apple's library) and a path lost its suffix. The byte scan O21's name refusal used moved to ffi.mojo as first_nul (a String.find, 36x faster than the loop at 4 MB), shared by all three sites; O21's message is unchanged and sabotage-sqlite-function's anchor for it is re-pointed (SPEC O30). prune_stamps rolls back only if db.in_transaction(), and always raises the error that stopped it: where SQLite had cancelled the transaction, its ROLLBACK raised "no transaction is active" (rc=1) in that error's place. A capped max_page_count cannot make the DELETE fail (it frees pages and needs none: measured at 10, 1000 and 20000 tombstones with max_page_count at the page count), so the test uses a trigger's RAISE(ROLLBACK), which cancels the transaction on every SQLite (O26). Each test fails with its fix reverted, on macOS: the closed-connection test at in_transaction (True), and at busy_timeout's and register_array_module's messages with those guards reverted alone; the three other accessors' guards show only on an unarmored library, where the call dies. The NUL tests at error_code (-1, not 21), the unfixed open having created the path's head; the prune test at error_code (1, not 19). Findings S1, S4 and S5. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
finalize() leaves a Statement's handle 0, and clear_bindings() passed it to sqlite3_clear_bindings, which checks for NULL only under SQLITE_ENABLE_API_ARMOR and otherwise reads p->db->mutex (3.53.4's source): the same crash as S1's, for a statement. Armored, Homebrew's 3.53.4 answers SQLITE_MISUSE and Apple's 3.54.0 SQLITE_OK, so on macOS the call silently did nothing. sqlite3_column_name is armor-only too; column_name() could not reach it after finalize, since _check_column reads a column count of 0 for NULL first, but no longer leans on that. Both now raise "... on a finalized statement", as Connection's guards do. The rest of the statement surface refuses NULL whatever the build, per the amalgamation: step and every binder (vdbeSafetyNotNull, MISUSE; bind_pointer and bindText run their destructor on the refusal), reset and finalize (no-ops), column_count (0), the other column readers (columnMem, columnMallocFailure), sqlite3_db_handle. No signature changed. test_sqlite.mojo:test_a_finalized_statement_is_never_passed_to_sqlite (O29) calls both guarded methods after finalize() and pins what the unguarded ones answer. Reverted on macOS: the clear_bindings guard fails the test (no error raised, Apple answering OK), the column_name guard fails it by its message (the column count's out-of-range refusal); a crash shows only on a library without the armor. O29, the CHANGELOG's S1 bullet (rewrapped) and README's counts follow. Finding S1. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…failed reset forgets no channel, a text integer never wraps (S2, S3, S8) notifies() ran PQconsumeInput before PQnotifies and raised when the consume failed. A burst read in one go and then the server's close left the rest in libpq's queue while the next call's read found the close, so it raised over them, and reset(), the recovery, frees the queue (pqDropServerData): all but the first were lost, unlogged. It now asks PQnotifies first, consumes only when the queue is empty, asks again (pqReadData keeps what it read on failure), and raises only when nothing is queued. A None still follows a read of the socket, so the docstring's "every notification ... until it answers None" holds, and pg_listen.mojo's loop (drain until None, reset on a raise) publishes the whole burst before it reconnects. reset() emptied _listening before re-listening, so a re-LISTEN that raised left only the channels restored before it, and every later reset restored fewer. It now walks a copy and never empties the list; listen() already records a channel once. Result.int's text parse wrapped past Int64 (2^64 read as 0). It now checks each digit against the limit before taking it, the negative limit one larger, so Int.MIN parses (SPEC O31, a new row; pure test). S6, S7: lib.mojo's opening paragraph said m0-sqlite links SQLite, false since O17/D49; notifies() claimed a header-derived PGnotify layout check that does not exist, and now says no gate compares the offsets. Tests, each failing with its fix reverted (macOS, PostgreSQL 17.9): - test_notify.mojo:test_queued_notifications_are_delivered_before_a_lost_connection sends two NOTIFYs from a second connection, lets a statement read them into libpq's queue, terminates the listener's backend (waiting for the process), and drains with a poll before every call. Unfixed it drains 1 of 2 before the raise, 10 runs of 10; fixed, 2 of 2 in 13 of 13. - test_notify.mojo:test_a_reset_whose_relisten_fails_forgets_no_channel makes a re-LISTEN fail for real: a channel named under LATIN1 is invalid UTF-8 after the reset, so PQescapeIdentifier refuses it. No re-LISTEN can be made to fail ONCE against a real server without a race (a standby, a drop between statements), so the test withdraws that channel, as an application would, and the next reset must restore the two around it. Unfixed it restores 1 of 2. - test_wire.mojo:test_a_text_integer_past_64_bits_raises_rather_than_wrapping "9223372036854775808" did not raise unfixed. O15's capability and evidence name the two notification fixes; CHANGELOG has three Fixed entries; README's counts are 84 and 1805. Findings S2, S3, S6, S7 and S8. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…one list under --blocking-threads, the scaffold ignores its leftovers, three smokes bind loopback (P1, P2, P10, P11) P1: the board template's frame carried all fifty messages, each escaped, and a frame over a slot's 64 KB outbox is refused for every slot, so the poster got a 200 and no tab updated. render_board now renders the newest messages that fit a byte budget (the outbox minus a framing margin, with the data: prefix counted per line break). _common/AGENTS.md says a full-state frame must fit the outbox. P2: views and auth routes ran on the pool, where each thread builds its own list; every route is now on_loop=True as board's are. The scaffold smoke serves both with --blocking-threads 2 and requires a created item in twelve later reads. P10: the scaffold's .gitignore lists .dist.next/, smoke.one, smoke.two and smoke.jar. P11: the views, auth and live smokes pass --host 127.0.0.1. SPEC N27, N45 and N52 extended; three sabotage rules added and one anchor re-pointed. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… it requires, m0 dev preflights each rebuild, the release build checks patchelf and fails on a lost rpath or signature, m0 new . accepts a .git, m0 image tags with the project's name (P3-P9) Findings: - P3: m0 doctor passed a bin/server signal death through as a negative exit (250 or 245 from sys.exit); it is now exit 1, "died of signal N". - P4: m0 new refuses (exit 2) a name the scaffold's pyproject pins (m0, mojo, max-core, mojo-compiler), read from the template. - P5: m0 dev re-runs the preflight before each rebuild; a refusal prints its sentence and the old server keeps serving. - P6: relocate.py and bundle_artifact.py no longer ignore a failed -add_rpath or codesign, and require every wanted rpath after. - P7: a patchelf check in the one list, asked by build --release and the doctor; named in MOJO_DEPLOY. - P8: m0 new . ignores a .git entry in both emptiness checks. - P9: m0 image defaults its tag to [project].name, else the directory. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s (P7 follow-up) A Linux machine that only runs m0 build needs no patchelf, so the doctor exiting 78 there was a regression. run_all skips RELEASE_ONLY checks; the check stays in the one list for build --release. The smoke's expected doctor list returns to six names; SPEC N25, MOJO_DEPLOY and the CHANGELOG no longer say the doctor reports it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…cepts the release-only patchelf (P7 follow-up) MOJO_HOST, packaging/m0 AGENTS.md and README, checks.py, doctor.py and image.py docstrings, and SPEC N25, N26 and N31 (with spec.json) still said the doctor runs every check. Wording only. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…l needs a delimiter after the literal, the crypto docstrings name today's callers, the JSON escape and string reader state their UTF-8 precondition (P12-P15)
P12: parse_json_int wrapped past 19 digits ({"n":18446744073709551617} read as 1).
P14: parse_json_bool read trueish and falsehood as literals.
P15: hmac.mojo and sha256.mojo named a pending login and SPEC D15; they name m0_http.session, m0_http.grant and DECISIONS D15 (retired).
P13 (owner's decision c): documentation only; no behaviour change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…play-frames cap follows the usage rules and says a journal needs --realtime, and the board template's line-break framing has a test (branch review F5, F6, M1, M8) F5: reset() stopped at the first re-LISTEN that failed, so one channel that could never be restored left every channel after it deaf. It now tries every channel, collects the failures and raises once at the end naming each. test_a_reset_whose_relisten_fails_forgets_no_channel asserts both neighbours of the failing channel listen right after the raise; against the old reset it got 1 notification of 2. SPEC O15 and the CHANGELOG entry say this. F6: test_a_board_of_line_breaks_still_reaches_an_open_stream, 50 messages of "a\n" * 750. With _framing_bytes returning 0 the frame is refused for every slot and the drain is empty. M8: the replay-frames rule moves after the flag-combination rules, so --replay-frames 5000 --pg-listen URL without --realtime exits 2 again, not 78. workers-vs-parallel-runtime stays last. The order test and a new smoke-doctor row pin it; the old binary answers that row 78. M1: the doctor's detail is the configured depth and says whether a journal is kept (only under --realtime). The refusal says the cap holds with or without --realtime. SPEC I43 and the CHANGELOG match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ss, the buffered ASGI path is named beside the header exclusion, and seven smaller statements match the tree (branch review I1, F1-F4, F7, M4-M7) I1: MOJO_VIEWS' first table is the views scaffold as it is now, every state route on_loop=True, and a sentence after the table says why: under --blocking-threads each pool thread builds a state of its own. The scaffold's AGENTS.md gives the same rule beside max_workers. F1: m0-wsgi AGENTS.md and header_is_excluded's docstring say the buffered ASGI path builds its scope from the environ, so the exclusion reaches ASGI applications under --blocking-threads N>0 too. F2: RUNNING.md gives the ASGI half in "Coming from uvicorn" and a bullet in "In front of it". F3: test-wsgi's help names the three files that embed CPython (test_blocking_pool as well as the two the review named). F4: SPEC O29's evidence adds Ubuntu 24.04, CI's Linux leg. F7: escape_json_string_into states the UTF-8 precondition. M4-M6: test_m0.py's docstring, the m0 QUICKSTART's doctor paragraph (prose only; the runner extracts the same eight blocks), and the four refused project names in the m0 README and SPEC N27. M7: SPEC N27, N45 and N52 name the three sabotage rules 593fb48 added, by the harness's labels. The views rule goes in N27, the row that describes views on the wire, since N45 is auth's and N52 is board's. Docs and docstrings only; no behaviour change, so no test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This was referenced Oct 11, 2026
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.
This fixes the 30 findings of the 2026-10-10 review of m0-wsgi, m0-sqlite, m0-postgres, the
m0wheel (CLI, templates, scripts) and m0-core. The review was read-only, atb21ba9a3. Each finding was checked against the tree before it counted. The two serious ones are:_reached the WSGI environ under the same key as its dashed spelling. So a client'sX_Forwarded_For, orX_Remote_User, could overwrite or forge what the proxy set. This is the CVE-2015-0219 class.close()orfinalize(). Debian 12's and Ubuntu 24.04's libsqlite3 are built withoutSQLITE_ENABLE_API_ARMORand segfault there; I checked both in containers, and both exit 139. Apple's library answered wrongly instead:in_transaction()said True on a closed connection.m0-datastar had no findings.
What changed, by finding
header_is_excludeddrops any header name containing_, as gunicorn has by default since 22.0. ASGI's executor still passes raw names. The buffered ASGI path (--blocking-threads) builds its scope from the environ, so it now drops them too. It had the same spoof.test_environ(G29); asmoke-wsgiwire assertionstate["m0"].publishnumbers frames from 1, as m0pub, pg-listen, WSHub and the host's Publisher do. The shim is re-rendered.smoke-asgi-fanout: the first id is 1, and m0pub +state["m0"]take distinct ids_event_head.test_hold_resume.mojo(I33)--replay-frames/M0_REPLAY_FRAMESabove 4096 is refused with 78 throughm0_wsgi.checks, so--doctoragrees.test_checks;smoke-doctorrows (I43)Last-Event-IDto the head, as SSE holds already did.--max-body/M0_MAX_BODYrefuses a size whose product overflows. That is exit 2 for the flag; the variable lands inenv_ignored.test_cli(C1)--doctorreportsreplay_frames: 0without--realtime.smoke-doctorConnection's accessors answer False or 0 without calling SQLite, andbusy_timeout/register_array_moduleraise. A finalizedStatement'sclear_bindings/column_nameraise.test_sqlite,test_file(O29): False on the Mac, a segfault on Linuxnotifies()drains what libpq already parsed before it raises on a lost connection.test_notify(O15), withpg_terminate_backendmid-burstreset()re-LISTENs every channel and raises once, naming the ones that failed. It no longer forgets the channels it had not reached.test_notify(O15), with a channel that cannot be re-LISTENed after a UTF8 reconnectexecute()and the constructors refuse an embedded NUL before calling C (rc=21).test_sqlite(O30)prune_stampsrolls back only inside a transaction, and keeps the real error.test_stamps(O26), viaRAISE(ROLLBACK)Result.intin text mode refuses an overflow and still parsesInt.MIN.test_wire(O31)boardtemplate's frame always fits the 64 KB per-slot outbox. It renders the newest messages that fit, with escaping and per-line SSE framing counted.test_boardtests: one with quotes, one with line breaksviewsandauthtemplates run their state routeson_loop=True, so one list serves every pool thread.MOJO_VIEWS.mdand the scaffold'sAGENTS.mdnow teach that.--blocking-threads 2; sabotage rulesm0 doctoris exit 1, inside the closed set 0/1/2/78.test_m0.pym0 newrefuses a project named for a package the scaffold pins (m0,mojo,max-core,mojo-compiler).test_m0.pym0 devruns the toolchain preflight before each rebuild.test_m0.pyrelocate.pyandbundle_artifact.pyfail on a failed-add_rpathorcodesign, and check that every wanted rpath arrived.test_m0.pybuild --releaseon Linux checks forpatchelfbefore it compiles.m0 doctordoes not ask for it.test_m0.pym0 new .accepts a directory holding only.git.test_m0.pym0 imagetags with[project].name.test_m0.py.gitignorecovers its leftovers, and three template smokes bind 127.0.0.1.parse_json_intanswers None outside Int's range, and both ends still parse.test_json_parseparse_json_boolneeds a delimiter after the literal, sotrueishgives None.test_json_parseEvery behaviour fix carries a test that was shown to fail with the fix reverted.
The owner's decisions
(a) W1 drops the header rather than answering 400. (b) W4 caps at 4096 through the checks list. (c) P13 is documentation only. (d) S1 gives neutral answers from accessors, and raises from methods that already raise. (e) This PR carries the
automergelabel.Choices made along the way
--realtime. It sits after the usage refusals, so a configuration that cannot be read still exits 2 first.m0 doctordoes not ask forpatchelf; onlybuild --releasedoes. e9d832c's message says the doctor asks for it. 3b751bf reversed that, and 0019473 corrected the prose.clear_bindingsandcolumn_namewere the only statement entry points whose NULL check is armor-only.How it was checked
build-all+test-allexited 0 in 677 s, with every suite at 0 failed. The warning ratchet holds at 0 against a baseline of 0.check-docspasses every part, and the gates for every touched area pass:sabotage-scaffold --skip image:: 71 rules caught. The new board rule was rewritten and is caught on its own.sabotage-m0-wheel: 26 of 26 caught.smoke-pg-notify(all four arms) passes against this branch'sbin/m0serve.Left for later
--replay-framescap (and arguably W1) narrows what a flag or variable accepts, as 1.13.0 did for the port. The### Fixedentries also want grouping by component.first_nul;checks.py,dev.py,MOJO_HOST.mdandlib.mojo.workers-vs-parallel-runtime, which applies only in a binary that links MAX, still comes after it.MAX_REPLAY_FRAMES's docstring lacks the "only under--realtime" qualifier.🤖 Generated with Claude Code