fix: refuse URL userinfo on the s3:// and gs:// paths (#995) - #997
Conversation
|
Closing a gap in my own evidence before anyone has to find it: the 11-suite gate in the body ran on the PRE-rebase tree, and #989 merged after I measured, and it renamed six check names in Re-ran the suites whose files actually differ between the measured tree ( The other nine are byte-identical to what I measured, which is why they were not re-run — stated so the scope of the re-run is visible rather than assumed.
Why this matters beyond tidiness: a gate statement is a claim about a tree, and a rebase silently changes which tree. I have now been caught by the same thing twice tonight from the other direction — #993's census needed re-deriving after two rebases — so the rule I am applying is: after a rebase, re-run whatever the evidence names whose files moved, and say which ones did not. |
jdatcmd
left a comment
There was a problem hiding this comment.
The guard is right, and I checked the frame around it rather than the five lines. Three findings, all in the evidence rather than the code. One of them I would like fixed before this merges; the other two are corrections to the PR text.
What I verified myself, from the source
| the scan is bounded to the bucket | memchr(bucket, '@', slash - bucket) — slash is non-NULL and > bucket, guaranteed by the format check four lines above, so the span cannot run off the end or into the key |
| both entry points are covered | os_resolve_s3 has exactly two callers, os_open (1549) and os_write_handle (1723), and your arms drive both |
| the listing path too | objstore_list_objects does its own parse, then builds s3://%s/_ from the bucket and calls os_write_handle, so the @ reaches the guard transitively |
| no false positive is possible | @ is not legal in an S3 or GCS bucket name, so nothing the guard refuses was ever a working read |
| the suite is registered | run_all_versions.sh:223, so CI actually runs it |
| CI ran it green | objstore_userinfo=PASS on both suites (PG 17) and suites (PG 18) at a1140288 |
Placing it before the endpoint resolution is right and it is not a detail. After it, every arm here would need an object-store fixture, and the suite would have been unable to say anything on a box without one.
I did not rebuild the module, so the on-main measurements — the HEAD /u%3Ap%40pgc-bucket/..., and the write raising no error — are yours, not independently reproduced here. I am taking them on your report and saying so.
1. An arm's name promises a string the arm never looks for
check "control: and that refusal names AWS_ENDPOINT_URL rather than userinfo" \
"$(msg_of "...s3://mybucket/x.parquet...")" "0"
msg_of is grep -c 'userinfo'. So this asserts only that the message does not say userinfo. A message saying anything else at all passes — including a message that never mentions AWS_ENDPOINT_URL. The name claims a positive the expression does not test.
That matters more than it looks, because 28000 is not unique to the endpoint demand: the region demand 25 lines below raises ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION too. So the pair 28000 + no userinfo does not pin which of the two refusals fired, which is exactly what the arm's name says it pins.
A second grep fixes it. control: a malformed s3:// URL is still the bucket/key refusal has the same shape — it asserts only the absence of userinfo, not the presence of the bucket/key refusal — and there the two refusals share SQLSTATE 22023, so the message is the only discriminator available.
The @-in-the-KEY control does not have this problem: "is not userinfo, and is not refused as it" is exactly what absence-of-userinfo shows, and you paired it with a 28000 arm proving the call got past the guard. That one is the model for the other two.
This is the only thing I would hold the merge for, and it is a two-line fix.
2. 5 arms 28000 cannot be the red for the gs arm
From the source, not from a run:
if (ep == NULL || ep[0] == '\0') {
if (isGs) ep = "https://storage.googleapis.com";
else ep = os_require_env("AWS_ENDPOINT_URL", url);
}gs:// defaults its endpoint, so it never reaches os_require_env and never raises the 28000 that the s3 arms get. On main, gs://u@mybucket/x.parquet goes on to the TLS check and then the allow-list — 42501, or 0A000 if the module was built without OpenSSL — none of which is 28000.
The arm is still genuinely red on main; I checked that before raising this, since red-for-a-different-reason is still red and none of those codes is 22023. What is wrong is the reported figure: "5 arms 28000" is a description of the group rather than a transcription of all five, and this repository's rule is that counts are printed from the data and never retyped into prose. Worth correcting in the body because the next reader will use that line as the removal proof.
3. The arm counts in the body are one revision behind
The heading says "Eight arms, five refusals and three controls" and the table lists four controls. The file adds ten checks: five refusals and five controls. Your own text has the right number further down — "15/15, then 17/17 with the key-@ controls" — so the heading simply did not get relabelled when the key-@ pair went in. Same class as the correction-dies-when-you-regenerate trap; the fix is to label the count with the revision it counted.
4. A nit, not a finding
A userinfo URL reaching objstore_list_objects is refused, but the message names the dummy URL the function synthesised:
columnar: userinfo in "s3://u:p@bucket/_" is not supported
The user never typed /_. It fails closed with the right reason, so this is cosmetic, and fixing it means passing the caller's URL down — out of scope here.
On the rebase
Thank you for re-running objstore_module and objstore_userinfo on a1140288 rather than leaving the 11-suite statement resting on the pre-rebase tree. That was the next thing I was going to ask for, and the gate statement now covers the tree that is actually proposed.
Verdict
Not approving yet, on finding 1 alone — the code is correct and I have no concern about shipping the guard. Fix the AWS_ENDPOINT_URL arm so it greps for AWS_ENDPOINT_URL, give the bucket/key control the same treatment, correct the two numbers in the body, and I will approve on sight without re-running anything.
a114028 to
612d81d
Compare
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at 612d81d6. The fix goes further than what I asked for, and the extra distance is the part worth naming.
The control now pins the refusal instead of its absence
msg_has() { # the ERROR line only, not the whole output
... | sed -n 's/^ERROR: //p' | grep -c -- "$2"
}
check "control: and that refusal NAMES AWS_ENDPOINT_URL, pinning which 28000 fired" \
"$(msg_has "..." 'AWS_ENDPOINT_URL')" "1"
check "control: and it does not name userinfo, so the new guard did not fire on it" \
"$(msg_of "...")" "0"Splitting the overclaiming arm into a positive and an absence is right — each now asserts exactly what its name says. Scoping msg_has to the ERROR: line is a step I did not ask for and would not have thought of: the HINT beside it also names the variable, so a whole-output grep would have pinned the arm to how many places the hint mentions AWS_ENDPOINT_URL rather than to which refusal fired. That is the same defect one level down, caught before it shipped.
And the gs control turns my second finding into an arm
I reported that gs:// cannot raise the endpoint 28000 because it defaults its endpoint. You measured what it raises instead:
check "control: the gs:// refusal names a CREDENTIAL, not the endpoint -- same 28000, other cause" \
"$(msg_has "...gs://mybucket/x.parquet..." 'AWS_ACCESS_KEY_ID')" "1"That is better than what I asked for. I said a number in the body was wrong; you made the tree assert the true cause, so the next person cannot restate the wrong one. It also documents that 28000 has three distinct sources in os_resolve_s3, which is the fact that made the original control weak.
Still outstanding: the body numbers
Both are unchanged at 612d81d6, and one has drifted further since I raised it:
| in the body | in the file |
|---|---|
| "## Eight arms, five refusals and three controls" | 12 added: 5 refusals and 7 controls |
| "green (this branch) 15/15, then 17/17" | 19/19 — origin/main 7, a1140288 17, 612d81d6 19 |
| "red (main's module, these arms) 5 arms 28000 -> FAIL" | the gs arm cannot have been 28000; your own new control now says why |
Counted from the files, not retyped: grep -cE '^check ' across the three revisions.
This is the [[a-correction-dies-when-you-regenerate]] shape — the heading was accurate when written and has been passed over by two rounds of edits that changed the thing it counts. Not a blocker, and I am not withholding approval for it, but the body is what the next reader uses as the removal proof.
Nit
The new comment credits @pgcolumnar-9b. That is my session name, not a GitHub account — it will not resolve for anyone reading the file later. jdatcmd, or just "reported in review on #997", would survive.
Verdict
Approved. The guard was already correct on the first read — bounds, both callers, the listing path via its synthesised URL, and no possible false positive since @ is not legal in a bucket name. The one finding I held it for is fixed properly rather than minimally. Fix the three body numbers when convenient; they do not change what ships.
612d81d to
14f295d
Compare
|
Re-verified on the pushed head Three corrections went in with it, two of them @jdatcmd's:
The branch history is rewritten so they were never there — |
14f295d to
a03c29d
Compare
) http(s) has refused userinfo by name since commandprompt#706. The s3/gs path absorbed it into the BUCKET NAME and told the caller the object did not exist: http://u:p@127.0.0.1:1/x.parquet 22023 userinfo in "..." is not supported s3://u:p@mybucket/x.parquet 58P01 "..." does not exist (HTTP 404) That message is true and useless. The object did not exist BECAUSE we made u:p@mybucket a bucket name. s3://user:key@bucket/obj is a form other tools accept, so it is a mistake a user makes, and the answer sent them hunting a missing object. MEASURED ON MAIN WITH AN ENDPOINT CONFIGURED, which is the only way to reach the authority parse at all: path style HEAD /u%3Ap%40pgc-bucket/vh.parquet, Host untouched -> HTTP 404 virtual host the bucket becomes the leftmost Host label, so the whole string goes to the resolver -> could not resolve "u:p@pgc-bucket.s3.local" write export_parquet raised NO ERROR and PUT to /u%3Ap%40pgc-bucket/ui.parquet -- a bucket nobody named NOT AN SSRF, and that was checked rather than assumed: nothing here splits the authority at '@', so the userinfo never becomes basic auth and never moves the host. The write row is why this is refused rather than documented -- acceptance there sends bytes to a bucket the caller did not ask for. The guard sits BEFORE the endpoint is resolved. Placed after it, the refusal would be unreachable whenever no endpoint is configured, because the s3 branch demands one first (28000) -- and the arms could then say nothing without an object-store fixture. Eight arms, five refusals and THREE CONTROLS. The controls are not decoration: my first probe of this gap proved nothing, because with no credentials every s3 URL returned 28000, THE CLEAN ONE INCLUDED, so a userinfo refusal was indistinguishable from an unreachable object store. They hold that a clean URL still reaches the endpoint demand, that a malformed URL still gets the bucket/key refusal, and that an '@' in the KEY is left alone -- the false positive this guard could plausibly have had. red -> green: 5 arms 28000 -> 22023, 3 controls PASS on both trees derived gate list = the 11 registered suites naming an s3:// or gs:// URL all 11 green on pg18a (183 checks, 0 FAIL) and on pg19a build preflight pg15a/16a/17a/18a/19a: rc=0, 0 warnings each Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
…commandprompt#995) Two review findings from @jdatcmd, plus one of my own mistakes. 1. THE CONTROL ASSERTED AN ABSENCE. It read: check "control: and that refusal names AWS_ENDPOINT_URL rather than userinfo" \ "$(msg_of "...s3://mybucket/x.parquet...")" "0" and msg_of is `grep -c 'userinfo'`, so it said nothing at all about AWS_ENDPOINT_URL. Its name claimed more than its value expression checked -- the same defect as my commandprompt#988 reason-detector matching the test's own name. It matters because 28000 is not unique to the endpoint demand: ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION is raised at three sites in os_resolve_s3 -- a missing endpoint, a missing credential, and the authorization refusal. A control seeing 28000 and no "userinfo" has pinned nothing. So there is a positive matcher now, and three controls where there was one: names AWS_ENDPOINT_URL 1 <- pins WHICH 28000 does not name userinfo 0 <- the new guard did not fire the gs:// refusal names AWS_ACCESS_KEY_ID 1 <- same 28000, DIFFERENT cause The third is worth a check rather than a comment: gs:// defaults its endpoint to the interop host, so it never reaches the endpoint demand and its 28000 is the credential demand. A reviewer reading the body concluded the gs arm "cannot have been 28000 on main"; it can, for the other reason, and an arm that names the variable settles it without anyone re-deriving it. THE MATCHER READS THE ERROR MESSAGE, NOT THE WHOLE OUTPUT. My first version grepped everything psql printed and returned 2, because the HINT also names the variable ("Set AWS_ENDPOINT_URL and restart"). Expecting 2 would have pinned the arm to how many places the hint mentions it. Removal proof per control, since a positive matcher that cannot fail is worse than the absence check it replaced: point it at a name nothing emits -> exactly 1 FAIL, the matching arm; restore -> 0 FAIL. 2. THE ARM COUNT. The body said eight, then ten; it is TWELVE. Counted with `grep -cE '^check '` across the revisions rather than retyped, which is how it drifted twice: main 7 check calls branch 19 check calls -> 12 added: 5 refusals, 7 controls 3. A CREDIT THAT WOULD NOT RESOLVE. A source comment read "Reported by @pgcolumnar-9b" -- a session name, not a GitHub account. An @ that resolves to nothing reads as a person who has left. It names the reviewing account now. objstore_userinfo 19 checks 0 FAIL | shellcheck -S error -s bash 0 | bash -n clean Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a
a03c29d to
3df6e67
Compare
http(s)://has refused URL userinfo by name since #706. Thes3://andgs://paths absorbed it into the bucket name and told the caller the object did not exist.That message was true and useless: the object did not exist because we made
u:p@mybucketa bucket name.s3://user:key@bucket/objis a form other tools accept, so it is a mistake a user makes, and the answer sent them hunting a missing object.Measured on
mainwith an endpoint configuredThat qualifier is the whole reason this is reportable. With no credentials the s3 branch demands an endpoint first, so nothing reaches the authority parse. Driven against
test/objstore_addressing.sh's fixture withAWS_ENDPOINT_URL, keys and region set (the fixture was temporarily instrumented to logHostandAuthorization; the instrumentation was reverted and the worktree verified clean):This is not an SSRF, and I checked specifically rather than assuming the worst. Nothing here splits the authority at
@: in path style the userinfo is percent-encoded into the first path segment and theHostheader is untouched; in virtual-host style the whole string is handed to the resolver as a literal hostname and fails at DNS. The userinfo never becomes basic auth and never moves the host.The write row is why this is refused rather than documented.
export_parquettos3://u:p@bucket/keyraised no error and PUT to a bucket the caller never named. On real S3 that isNoSuchBucket— a failed export with a misleading reason — but the acceptance is ours either way.Where the guard sits, and why that is not incidental
Immediately after the
bucket/keyformat check and before the endpoint is resolved. Placed after it, the refusal would be unreachable whenever no endpoint is configured, because the s3 branch demands one first (28000) — and the arms below could then say nothing without an object-store fixture. One guard covers both schemes, sinces3andgsshare this function.Twelve arms, five refusals and seven controls
The controls are not decoration. My first probe of this gap proved nothing: with no credentials every s3 URL returned
28000, the clean one included, so a userinfo refusal was indistinguishable from an unreachable object store. The arms would have had the same defect.Counted, not retyped —
grep -cE '^check 'gives 7 onmainand 19 on the branch, so 12 added. The body said eight and then ten before this; both were wrong, which is why the number is now derived from the file.s3://u:p@…22023, and the message names userinfogs://u@…export_parquettos3://u:p@…s3://URL is not refused as userinfo — it reaches the endpoint demandAWS_ENDPOINT_URL, not userinfos3://nokeystill gets the bucket/key refusal@in the KEY (s3://bucket/my@file.parquet) is left aloneAWS_ENDPOINT_URL, pinning which28000firedgs://refusal namesAWS_ACCESS_KEY_ID— same28000, different causeThe last one is the false positive this guard could plausibly have had —
@is legal in an S3 key. The guard scans only up to the first slash, and that is asserted rather than left to the comment.Red to green, and the gate bar for a C change
The gate list was derived, not guessed:
grep -rlE "s3://|gs://" test/*.sh, intersected with the runner'sSUITESarray. All 11 are registered, so none of them is a suite CI would skip.Limits of what this proves
AWS_ENDPOINT_URL='http://u:p@s3.local:PORT'is already refused at22023, but ashas an invalid host or portrather than as userinfo. Fail closed with an imprecise reason. Changing that message is sec/mod: userinfo '@' guard missing on objstore write path (os_write_handle) #706's author's call, so s3:// and gs:// absorb URL userinfo into the bucket name instead of refusing it, so the user is told the object does not exist #995 stays open for it.NoSuchBucket) is reasoning, not a measurement; what I measured is that our code accepted the URL and sent a PUT.sent=[]was a GET-only grep missing theHEAD, and the first write probe died at42809because I created a heap table instead of a columnar one. Both were caught by the controls rather than by me reading the script.No closing keyword, deliberately. This closes the bucket-authority half of #995; the endpoint-message half is still open there, and a keyword here would take the whole issue with it. Part of #995.
🤖 Generated with Claude Code
https://claude.ai/code/session_01Uf6UoeBRZYLQZa4KxNiw8a