epic 2.1: a malformed body answers 400, and a redirect cannot point at itself - #53
Merged
Merged
Conversation
express.json() runs in strict mode, so a body of null, "x" or 5 never reaches a
route. body-parser throws first, carrying status 400, expose: true and
type 'entity.parse.failed'. The error handler ignored all three, answered
{"error":"internal server error"} with a 500, and logged a stack trace per typo.
A client could not tell "I sent bad JSON" from "the server broke", and a retry
loop keyed on 5xx retried a request that can never succeed.
The decision about which errors get to name themselves moves into
lib/errors.js as clientFacingError(), so a test can hand it the shapes
body-parser throws without booting express. It answers a parse failure as 400
"invalid JSON body", keeps the existing 413 for a body past the limit, and
passes through any other body-parser refusal (an encoding or charset it cannot
read) on its own message. A message only leaves the process when the error
carries both expose: true and a 4xx status: expose alone shows up on errors
this repo did not write, and a 4xx alone says nothing about whether the text
names an internal path. A 5xx never speaks for itself. Everything else still
gets the bare 500 and the log line.
Client errors are no longer logged. A malformed body is a typo on the other
end, and a stack trace per typo buries the logs that matter.
Smoke covers the 400 half on POST /api/artifacts across four bad bodies, checks
the message names the format, checks a good body with a bad field still gets the
route's own message, and checks nothing about the parser leaks. The 500 half has
no trigger from outside a healthy server, so it is pinned in test/errors.test.js
rather than faked in smoke.
Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
…redirect count Two halves of the same item, both known and accepted when T2.1.4 merged. A redirect whose target is its own slug answers its own 301, so a visitor's browser hops until it gives up on an error page. The server never follows a target, so nothing amplifies, but nothing caught it either and the publisher found out from a browser rather than from the API. pointsAtOwnSlug() in lib/redirect.js decides what "its own slug" means: the BASE_URL host, either scheme (a proxy terminates TLS and the origin behind it answers on the other one), the path /a/<slug> with or without a trailing slash, and the path segment decoded first because express decodes it too. It runs on POST, on a PUT that repoints a slug, and on a PATCH that renames a redirect onto the slug it already targets, which closes the same loop with no target changing. Two artifacts pointing at each other, and a target on another host that redirects back here, still get through: both need a lookup a publish does not have. The volume half surfaces rather than caps. A cap changes what an existing key is allowed to do, which is the self-hoster's call to make, and a count they can see is what tells them to make it. A redirect now stores the managed key that published it in meta.keyId, and GET /api/keys counts them per key off the stored records, so a delete comes off the count and a repointing PUT is the same hop rather than a new one. keyId is not in PUBLIC_META_FIELDS, so it never rides out on the artifact list every read-scoped key can call. Publishes from the bootstrap key or a dashboard session name no key and land in nobody's count. The dashboard key row and `artifacts keys list` print the number on keys that have any, and leave it off the ones that do not. listArtifactMetas() splits out of listArtifacts so the key route can read a field the list API does not hand out. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
Three review passes found eleven problems in the redirect self-reference guard, the per-key redirect count and the body-parser error contract. Every one was reproduced against a running server first. Duplicate skipped the self-reference guard. POST /api/artifacts/:slug/duplicate re-parsed the target but never asked whether it pointed at the slug the copy was landing on, so duplicating a hop onto the very slug it targets built the loop a direct publish refuses. The check now runs before storage.copySlug, off the original's stored target, so a refusal writes nothing. Duplicate dropped key attribution. The route passed no keyId and the copied meta never carried one, so a key could mint hops through /duplicate all day while GET /api/keys stood still. req.principal.keyId now rides through duplicateArtifact into the copy, the way storeArtifact already takes it. A dashboard or bootstrap PUT erased the attribution. storeArtifact cleared meta.keyId whenever the principal had none of its own, so repointing a leaked key's redirect, which is the first thing an operator does, deleted the count that showed them the leak. The redirect branch now sets keyId when there is one and leaves what is stored alone otherwise. The non-redirect branch still clears it. The rename guard missed a target stored only in source.url. applyPatch read meta.target straight off the record. On a redirect published before that field existed, new URL(undefined) threw inside the check, which answered false, and the rename closed the loop. The target is now resolved the way the serve path resolves it, through the new storedTargetPointsAtSlug helper. pointsAtOwnSlug missed three shapes, all live loops. Express routes case-insensitively, so /a/CASE-LOOP reached slug case-loop and walked past a case-sensitive compare. A trailing dot on the host is the same name to DNS but not to a string compare, so http://localhost.:3021/a/dot-loop was accepted. And decodeURIComponent ran over the whole path, so /a%2fhop decoded to /a/hop and was refused even though it reaches a 404. The compare now folds case, strips one trailing dot from each host name, and splits the path before decoding the captured segment, which is what express does. docs/api.md claimed a contract the code no longer honours. It said every failure other than the JSON one is a bare 500. Any body-parser refusal with a 4xx and expose set answers for itself now: Content-Encoding: br returns 415 with the parser's own text. The bullet lists the full set that can reach a client, all of it caller-supplied. "invalid JSON body" named the wrong problem. null, 5, "x" and true are all valid JSON; what refuses them is express.json() strict mode wanting an object or an array. The message is now "invalid JSON body: expected a JSON object". clientFacingError returned an ApiError's message at any status. No route builds a 5xx one today, so nothing leaked, but new ApiError(500, err.message) would have shipped an internal string the day someone wrote it. A 5xx, or a status that is not an integer, now falls through to the bare 500. The redirect count covers stored records, not serving ones. countRedirectsByKey counts disabled and expired redirects too. Rewording was smaller than filtering, and a record that is switched off is one PATCH from serving again, so docs/auth.md and docs/formats.md now say stored and spell out what counts. The self-reference guard is documented as what it is. It catches the typo, not the attacker: it is off entirely on a deploy that never set BASE_URL, and any other name or IP for the same server walks past it. The comment in lib/redirect.js and the bullet in docs/formats.md say so instead of implying it prevents loops. POST /api/keys now returns redirects: 0, so a create response has the same shape as a row from GET /api/keys. Tests: 122 unit tests pass, up from 120. New smoke cases cover the duplicate guard, the duplicate's attribution, and a replace by a different principal than the one that published, which is the case the existing replace test missed. The suite is 217 ok lines, exit 0. Claude-Session: https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea
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.
Two items from epic 2.1, plus one commit closing every finding the review gauntlet confirmed.
Items
Tests: 108 to 122, no failures. Smoke: 215 to 217 ok-lines, exit 0.
T2.1.18 - a malformed request body answers 400 instead of 500
express.json()runs in strict mode, so a body ofnull,"x"or5never reached a route.body-parser threw a SyntaxError carrying
status: 400,expose: trueandtype: 'entity.parse.failed', and the error handler ignored all three: every typo answered{"error":"internal server error"}with a 500 and a full stack trace in the log. A client could nottell "I sent bad JSON" from "the server broke", and a retry loop keyed on 5xx retried a request that
could never succeed.
lib/errors.jsgainedclientFacingError(err), which holds the whole decision about which errorsget to name themselves, so it can be tested without going through express. The rules: a parse
failure answers
400 invalid JSON body: expected a JSON object, a body over the limit keeps theexisting 413, and anything else needs
expose === truetogether with an integer 4xx status beforeits message leaves the process. A 5xx never speaks for itself, and an
ApiErrorbuilt with a 5xx isrefused there too so the next person who writes
new ApiError(500, err.message)cannot leak throughit. Client errors are no longer logged.
The message names the real problem rather than the apparent one.
null,5,"x"andtrueareall valid JSON; what refuses them is strict mode wanting an object or an array, so "invalid JSON"
alone sent a reader hunting for a syntax error that was not there.
Six parser messages can now reach a client that could not before, all of them caller-supplied text
echoed back:
invalid JSON body: expected a JSON object(400),unsupported charset "X"(415),unsupported content encoding "X"(415),content encoding unsupported(415),request aborted(400), and
request size did not match content length(400). The security lens walked the dependencytree for anything else carrying
expose: trueand found the two candidates both blocked:sendforces
expose: falseon its fs errors, and express never setsexposeon a param-decode URIError.docs/api.mdused to promise that every failure other than the listed ones was a bare 500. Thatsentence is now false, so it was rewritten rather than left to rot.
Tests:
test/errors.test.jscovers the decision directly, red first atSyntaxError: The requested module '../lib/errors.js' does not provide an export named 'clientFacingError'.Smoke covers all four bad-body shapes on
POST /api/artifacts.The bare-500 half of the done-when is proved by unit test, not by smoke. Every throw reachable over
HTTP is an
ApiErrorwith its own status, so triggering a real internal error from a shell scriptneeds a broken filesystem. The QA lens confirmed the behaviour out of band by making the data
directory read-only:
500 {"error":"internal server error"}with the EACCES stack going to stderronly.
T2.1.8 - a self-referencing redirect is refused, and each key shows its redirect count
A redirect target pointing back at its own slug looped until the visitor's browser gave up, and
nothing detected it. Separately, nothing surfaced how many redirects one publish-scoped key had
minted, which is the knob a self-hoster wants when a key leaks and the domain starts hosting phishing
hops.
lib/redirect.jsgainedpointsAtOwnSlug(target, slug, baseUrl)andcountRedirectsByKey(metas).The refusal runs on POST and PUT before the first
storage.put, on a PATCH rename beforestorage.move, and on duplicate beforestorage.copySlug, so a refusal writes and moves nothing.Redirects now store
meta.keyId,GET /api/keysreports aredirectscount per key, and both theCLI and the dashboard key row show it when it is not zero.
On the "capped or surfaced" choice in the done-when: surfaced. A cap changes behaviour for anyone
already using redirects and needs a number nobody has picked; a count needs no decision and is the
thing an operator actually looks at.
keyIdis not inPUBLIC_META_FIELDS, so it never reachesGET /api/artifacts. Smoke asserts that,and the security lens checked every other way a meta object leaves the process (the serve paths,
/source, the MCP tools, the dashboard payload, capability links, the QR route,?raw=1) and foundno leak.
What the guard is, plainly. It catches the typo, not the attacker. It compares the target's host
and port against
BASE_URL, so it is off entirely on a deploy that never setBASE_URL, and anyother name or IP for the same server walks past it. Two redirects pointing at each other, and a
cross-host chain that comes back here, both need a lookup a single publish does not have. All of this
is written into the comment and into
docs/formats.mdrather than being sold as loop prevention.Tests: four cases in
test/redirect.test.js, red first atReferenceError: pointsAtOwnSlug is not defined. Smoke covers three target shapes on POST, a PUTrepoint, a PATCH rename onto the target, a check that the refused rename left the artifact in place,
and the count going 0 to 3 with html not counted, a PUT not double-counting, and a delete
decrementing.
Review findings
Three lenses ran against the branch: adversarial, security, and QA plus UX. Eleven findings were
confirmed and reproduced against a live server. All eleven are fixed in f3f6b4b. None were filed for
later.
The two that mattered. Both were in the feature this branch shipped, and both were reachable
through a supported route.
POST /api/artifacts/:slug/duplicateskipped the self-redirect guard entirely.copyArtifactre-ran
parseRedirectTargetbut neverpointsAtOwnSlug, and its own comment said "a copy is apublish, so it answers to the publish rules", which was the one rule it did not answer to. Publish
pub-srcpointing at/a/pub-dst, duplicate it to slugpub-dst, and the copy served a 301 toitself while the identical target was refused on a direct POST.
The same route dropped key attribution, so the count undercounted. A key at
redirects: 1couldduplicate its own redirect twice, end up with three live hops, and still read 1. That is one extra
publish-scoped call to defeat the number the feature exists to surface.
The rest.
PUTerased the attribution, because the redirect branch clearedmeta.keyIdwhen the caller had none. A dashboard session has nokeyIdat all, so repointing aleaked key's hop from the dashboard, which is exactly what an operator does on finding one, reset
the count to zero. The clear is gone from the redirect branch and stays in the other.
meta.targetdirectly, so a redirect published before that field existed(target only in
source.url) renamed straight into a loop. It now resolves the target the way theserve path does.
pointsAtOwnSlug, each reproduced as a live loop: an uppercase path(express routes case-insensitively, the compare did not), a trailing-dot host (
localhost.), anddecodeURIComponentover the whole pathname turning/a%2fhopinto a false positive. The comparenow uses hostname plus port with one trailing dot stripped, and decodes only the captured segment,
which is what express does.
clientFacingError'sApiErrorbranch returnederr.messagefor any status. Inert today, sinceno
ApiErroris built with a 5xx, and now refused outright.docs/api.mdpromised a contract the code no longer honoured. Rewritten with the six parsermessages listed.
docs/auth.mdanddocs/formats.mdcalled the count "live" while it counts disabled and expiredredirects too. Reworded to "stored", which is smaller than filtering and truer: a disabled record
is one PATCH away from serving.
POST /api/keysreturned a shape withoutredirectswhileGET /api/keyshad it. Both clientsalready guarded the missing field, so this is one line for shape parity.
Smoke gained a case per behaviour fix. The replace case now uses a different principal from the one
that published, which is why the erased-attribution bug got past the first pass.
Confirmed and deliberately not fixed. Each is either pre-existing or needs a decision this branch
should not make.
GET /api/keysnow reads every artifact's meta to build the count.GET /api/artifactsalreadydid the same scan at a lower scope, so this is an operational cost on a large instance, not a new
vector.
/api/*and/mcpare neither logged nor rate limited, so key grinding leavesno trace. Pre-existing, outside this branch, and worth its own item.
publish does not have. Documented in
docs/formats.md.pointsAtOwnSlugassumesBASE_URLhas no path prefix. Routes mount at the root in this app./mcpanswers a plain{"error":...}rather than a JSON-RPC error object. Itdid this before too, as a 500, so the shape is wrong but unchanged.
Verification
npm test: 122 pass, 0 fail. Was 108 on main.bash .github/workflows/smoke.sh http://localhost:3011 testagainst a clean data directory:exit 0, 217 ok-lines,
all smoke tests passed. Was 215 before the fix pass.git diff origin/main..HEAD -U0 | grep "^+" | grep "—\|–"returns nothing.https://claude.ai/code/session_01JPiFnXvsTMjDJUdRZNwTea