Reject malformed hexadecimal in machine:readmem, machine:writemem and machine:debugreg - #884
Conversation
strtol() yields 0 for an unparseable address, and the end pointer was passed as NULL and never checked, so a non-hex address passed the 0..65535 range check and the request was served against $0000. For writemem that is a destructive write to zero page answered with HTTP 200: the response even names "0000-..." as the address it wrote. Check the end pointer at the three handlers that parse an address -- PUT and POST machine:writemem, and GET machine:readmem -- rejecting input that consumed no digits or left trailing characters, folded into the existing range check so the error path is unchanged. 400 "Invalid address" is already the documented response at all three, so the API documentation is unaffected. An optional 0x prefix still parses; overflow needs no errno check because strtol's LONG_MAX/LONG_MIN are outside the accepted range. Cover it with a bad-address stage in the readmem/writemem E2E suite, gated through the FIXES table so machines whose released firmware predates the change skip with a named reason rather than going red. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
Thank you for putting this together. The focused regression coverage and the care around reaching each handler’s address parse are especially useful. While auditing related handlers I found the same unchecked hexadecimal conversion in U64-only It may also be worth sharing the parsing/validation logic rather than adding another copy. |
strtol skips whitespace and takes a sign, so "-0", "+1" and " 1234" still reached an address. parse_address accepts hex digits only.
|
Pushed a commit addressing both review comments. Revert it if you disagree with Sign and whitespace. De-duplication. One helper, and the three endpoints are five lines each.
Verified on an Ultimate 64 Elite by JTAG-deploying both states:
Compiles for u64 and u64ii; |
The same parser, with a byte limit. "ZZ" wrote 00 and "1FF" wrote FF, both answered HTTP 200.
|
Also folded in #885, since it is the same parser. Measured on an Ultimate 64 Elite with the register set to After, all five are 400 and the register is unchanged. Rerun on both machines: u64 78/78 across all eight stages, I also retitled the PR, since it now covers three endpoints rather than two. |
PR GideonZ#884 made writemem/readmem parse the address to the grammar the API documents: hex digits only, no leading "0x". The soak probe's memory ops still sent "0x0000", "0x0400", "0xD000", "0xD7FF" and an "0x"-prefixed per-runner write address, which strtol used to accept. Against patched firmware those requests now answer 400, and memory_read/memory_write_verify raise on any non-2xx, so the probe would break on the very firmware this change ships with. Send the four fixed addresses and the per-runner write address as bare 4-digit hex so the probe exercises the endpoints the way a well-behaved client is now required to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
* Send un-prefixed hex addresses from the HTTP soak probe PR #884 made writemem/readmem parse the address to the grammar the API documents: hex digits only, no leading "0x". The soak probe's memory ops still sent "0x0000", "0x0400", "0xD000", "0xD7FF" and an "0x"-prefixed per-runner write address, which strtol used to accept. Against patched firmware those requests now answer 400, and memory_read/memory_write_verify raise on any non-2xx, so the probe would break on the very firmware this change ships with. Send the four fixed addresses and the per-runner write address as bare 4-digit hex so the probe exercises the endpoints the way a well-behaved client is now required to. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Write the held debug-register value back without an "0x" prefix The debugreg PUT case writes the register's held value straight back and asserts the read-back is unchanged, but formatted it as f"0x{before}". strtol consumed that prefix; parse_hex does not, so against this branch's firmware the request answers 400 and set_debugreg raises on the non-200, failing the case. Measured on an Ultimate 64 Elite still running the pre-PR firmware, where the prefix is still accepted: the suite passes 85 checks with this case green, and PUT machine:debugreg?value=0xAA answers 200 with the register unchanged at AA. Both go away once the stricter parse ships. The register reads back as two hexadecimal digits, which is already the documented form, so the prefix can simply go. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * Keep debug-register validation state-neutral --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Christian Gleissner <chrisgleissner@gmail.com>
What's wrong
/v1/machine:writememand/v1/machine:readmemparse theaddressquery parameter withThe end pointer is passed as
NULLand never checked, sostrtol()silently yields0for inputthat is not a number at all.
0then passes the(address < 0) || (address > 65535)range check,and the request proceeds against
$0000.For
writememthat means a destructive write to zero page, answered with HTTP 200. Measured onan Ultimate 64 Elite (fw 3.15):
The firmware names
$0000in its own success payload — it reports success for a write the callernever asked for.
Also #885
PUT /v1/machine:debugreghad the same defect in itsvalueparameter, foundwhile reviewing this PR and filed as #885. It is fixed here because it is the
same parser:
ZZ,0xZZand-0wrote00,1Gwrote01, and1FFwastruncated to
FF, each answered HTTP 200 with the register changed.Scope
Reaching this requires sending an address that is not hexadecimal, which a careful client will not do
deliberately — but a typo, an unvalidated string, or a programmatically built query does it easily, and
the failure is silent: the caller is told 200 while zero page is overwritten. The argument for the
change is the API contract rather than a report of damage in the field: an unparseable address should
be refused, not resolved to
$0000and acted on.The change
One parser, used by the three handlers that take an address — PUT
writemem,POST
writemem, GETreadmem. It implements the grammar the API documents,"Start address in hexadecimal, 0000 to FFFF", and nothing else, built on the
chartohexhelper already at the top of the file:machine:debugreguses the same parser with a limit of0xFF, so anout-of-range byte is refused rather than truncated. Its API doc gains the
400 Invalid valueresponse it now returns, anddoc/api/rest_api_openapi_u64.yamlis regenerated to match —
make openapi_checkis a build gate, so thecommitted document cannot drift from the source.
Each handler is then:
Notes on the shape:
400 Invalid addressis already the documented response at all threehandlers, so the API docs and the generated OpenAPI are unchanged.
is read and no
errnoor overflow reasoning is needed.strtolin four ways, all of them the documentedgrammar: leading whitespace, a leading sign, a
0xprefix and trailingcharacters are all refused.
" D020","+D020","-0","0xD020"and"D020 "are 400s.0xprefix is the one form that used to be accepted and now is not. Noclient in this repository sends it: the harness formats
%04X, and thefirmware's own web UI uses
toString(16).padStart(4, "0")and literalfour-digit strings, with its typed-address path validating as a 16-bit hex
number first.
Test
New
bad-addressstage intests/e2e/api/readmem_writemem_test.py, gated through the existingFIXEStable (memory-api-rejects-invalid-address,lacking=(C64U, U2)) so machines whosereleased firmware predates the fix skip with a named reason instead of going red.
BAD_ADDRESSEScoversZZZZ,0xZZZZandgggg(nothing parses),-0and+1(a sign, whichstrtoltakes), and12GG(a valid prefix with trailinggarbage).
A second stage,
bad-debugreg, covers #885 withZZ,0xZZ,-0,1Gand1FF.machine:debugreghas a GET as well as a PUT, so "the refused requestwrote nothing" is asserted directly: the register is read before and after each
rejected write and has to be unchanged. It is gated on its own fix entry,
debugreg-rejects-invalid-value, and skips where the route does not exist,since both debugreg routes sit inside
#if U64.The stage sends a valid
data=on the PUT case and a real body on the POST case deliberately:without them the request is refused earlier — 400 for a missing required parameter, 412 for a
missing body — and the check would pass for a reason unrelated to the address. The HTTP status is
the assertion;
$0000is the 6510 processor port rather than plain RAM, so reading it back is not areliable witness.
Rig proof
Both halves on the same Ultimate 64 Elite, by JTAG-deploying a build of the
parent commit and then a build of this branch.
Before, every malformed address is accepted on both endpoints:
and the stage fails on the first check:
machine:debugregbefore, with the register set to1Ffirst:After, the stages are green and so is the rest of the suite:
boundsstill passes, so the numeric out-of-range cases (10000,-1) areunaffected: both were 400 before and are 400 now, refused at the parse rather
than by the range check.
Wider run,
./run-tests --profile quick u64: 25 of 26 suite runs pass. The onefailure is
rest-api-coveragecheck 10,git_commit_hash is not a commit hash: '', with 78 of its 79 checks passing. That is an artefact of building in a gitworktree —
target/common/rules.mkrunsgit rev-parse --short HEADinside thebuild container, where a worktree's
.gitpointer file resolves to a path thatis not mounted, so
APP_VERSION_HASHcomes out empty. It is independent of thischange and does not occur for a CI build.
On
u2@c64u, with the Ultimate II+L flashed from this branch's CI artifact(
git_commit_hash 38033b87) and the fix table assumption lifted so the stageruns rather than skipping:
The RISC-V build rejects the same six addresses on all three endpoints, so the
parser behaves identically on both CPUs.
bad-debugregskips there, becauseboth debugreg routes are inside
#if U64and a cartridge does not serve them.tests/lib/openapi_contract_test.pyandtests/e2e/api/openapi_contract_test.pyboth pass against the rebuilt document and the running device.
Compiles for u64 and u64ii;
route_machine.oalso builds and links for u2pl'sRISC-V target.
Not covered
The same unchecked
strtol(..., NULL, ...)pattern exists elsewhere —route_machine.cc's debugregvalue,routes.h'sget_int(benign forlength, whose range check catches0), androute_configs.cc/json.cc. Left alone here to keep this change to the address parse; happy tofollow up separately if wanted.
🤖 Generated with Claude Code