fix: refuse userinfo in an object-store endpoint (#995) - #1064
Conversation
commandprompt#997 closed `s3://u:p@bucket/key` -- userinfo in the URL the CALLER writes. The endpoint the OPERATOR configures was still unguarded, and the bucket guard cannot see it, because it is not in the URL at all. TWO SHAPES, AND ONLY ONE WAS EVER CAUGHT. Measured on the authority parse before writing anything: http://u:p@host:30829 -> host "u", port 0 refused, wrong reason http://user@host:30829 -> host "user@host", port 30829 port VALID http://host:30829 -> host "host", port 30829 (clean control) The first has a colon INSIDE the userinfo, so the authority split lands there and the port becomes atoi("p@host:30829") = 0. The second has no such colon: the real port survives, the '@' rides along in the host, and the invalid-port refusal never fires at all. commandprompt#995 measured the first and concluded "refused as invalid host or port" -- true of that shape rather than of the code. THE SECOND SHAPE IS WHY THIS IS A GUARD AND NOT A MESSAGE CHANGE. Its refusal came from the allow-list, naming the host it could not match, and the hint then told the operator: ALTER SYSTEM SET pgcolumnar.objstore_allowed_endpoints = 'user@host' A diagnostic that invites widening a security boundary to accommodate a parse bug is worse than a wrong error code. Following it moves the failure from the allow-list to a DNS miss. PLACED BEFORE THE SCHEME AND REGION DEMANDS, not at the authority parse eighty lines later. Placed there the guard is unreachable whenever no region is configured, and an operator with a userinfo endpoint and no AWS_REGION is told about the region. MEASURED: every endpoint arm returned "requires a region option" until it moved -- the unreachable guard trap commandprompt#995 itself names. The message names the ENDPOINT rather than the URL, because that is the string carrying the userinfo. Removal proof, on both harnesses, each run asserting the .so was REBUILT AND INSTALLED (mtime moved) before its verdict is read: bash CONTROL 26 passed guard removed 5 failed restored 26 passed pytest CONTROL 5 passed guard removed 3 failed restored 5 passed Source restored to md5 0de468b1 after each. TWO TRAPS WALKED INTO WHILE MEASURING, both already in the record: `PGC_SKIP_BUILD=0` SKIPS THE BUILD. lib.sh tests `[ -z "${PGC_SKIP_BUILD:-}" ]`, so any non-empty value means skip -- including `0`, which reads as "do not skip". The first run measured a .so from the previous day and reported five failures against code that did not contain the guard. The suite said so on line 5 of its own output and I did not read it; the timestamps did. `git checkout -- FILE` REVERTED THE UNCOMMITTED FIX. The removal proof's restore took the file back to HEAD, which did not have the guard, so the final control failed identically to the mutant. Caught because the restore's md5 assertion did not fire. Fixed by committing before mutating. Arms in test/objstore_userinfo.sh beside the existing ones, with a clean-endpoint control in the same run, and independently in test/pytest/test_objstore_endpoint_userinfo.py -- which is NOT a port and not a pair, since `objstore_endpoint_userinfo.sh` does not exist. objstore_userinfo.sh 26 checks, 0 failed (pg16a) guard leg 340 passed, 867 checks, 0 fail cluster leg 325 passed, 898 checks, 0 fail `cluster_tests` 320 -> 325 by collection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
|
The open wording question is settled: the message stays as it is. Recording the reason here rather than leaving it in a side channel, because "why is this one phrased differently" is exactly the question a future reader will ask. @jdatcmd's argument, and it is not stylistic: The three name the URL the CALLER wrote. This one names the endpoint the OPERATOR configured. A caller who wrote a perfectly clean The version here also agrees with its own hint — the message names the endpoint, the hint says remove it from the endpoint — so message and hint point at the same object. That is the property worth preserving; uniform phrasing across a different subject is not. If exact uniformity across all four is wanted, the right move is to make the subject explicit in all of them — CI is 14/14, 0 pending, 0 failures on |
jdatcmd
left a comment
There was a problem hiding this comment.
Approving at 1850be81, 14/14. Being explicit about which parts I verified myself and which I am relying on you for, since I did not rebuild — the box is mid-measurement for #890.
Verified directly from the source
The placement is real and checkable from the line numbers:
1435 if (strchr(ep, '@') != NULL) <- the guard
1479 "requires a region option" <- the demand that made it unreachable
Guard above demand, which is what lets it fire with no region configured. That is the whole finding, and it is visible without running anything.
The arms cover the direction that matters. test_an_at_sign_in_the_object_key_is_not_userinfo is the one I would keep hardest — @ is legal in an S3 key, and without that arm every other arm here would pass equally well against a guard that refused any @ anywhere. That is "make it say the other thing" applied properly rather than described.
test_the_guard_fires_without_a_region_configured is the placement claim stated as a test instead of trusted to a comment. A comment saying "this must go above the region demand" rots; an arm that fails when it moves does not.
Relying on you for
The removal proof (26 -> 5 -> 26 and 5 -> 3 -> 5, each asserting the .so mtime moved first) and the two suites legs. I have not rebuilt, so those are your measurements, not mine. Flagging it rather than implying I re-ran them.
On the wording
Answered in the channel and you have already put it on the PR, which is the right place — "why is this one phrased differently" is what a future reader asks, and the answer belongs where they will look.
The traps
PGC_SKIP_BUILD=0 skipping the build is a genuinely nasty one: the value that reads as "do not skip" is the value that skips, and the suite announced it on line 5 of its own output. What caught it was the .so timestamps, not the announcement — a printed fact nobody reads is not a guard.
And git checkout -- FILE reverting an uncommitted fix, so the control fails identically to the mutant: that one produces a removal proof where both arms are red for the same reason, which reads as a working proof if you only check that the mutant reddened. Your restore md5 assertion is what made it visible.
…he section commandprompt#1064 shipped unnumbered commandprompt#1064 ADDED A NEW INSTANCE OF THE DEFECT THIS BRANCH CATCHES, which is why the merge cannot be resolved by taking a side: either side leaves `test_objstore_endpoint_userinfo.py` as an unnumbered `###`, and the arm this branch adds reddens on main the moment it lands. main:3879 ### `test_objstore_endpoint_userinfo.py` -- userinfo in an object-store endpoint (commandprompt#995) Not in the numbering, not in the contents, still NAMED so the coverage arm passes. The same shape `test_iceberg_fdw.py` arrived in during commandprompt#1057, in the very next PR, written by the person who had just measured it. Found by @jdatcmd reviewing this branch against the merged tree rather than against either side of it. RESOLUTION, and the guard is the arbiter for every part of it: - the conflicted hunk keeps this branch's `## 37. test_iceberg_fdw.py` - main's two `###` headings go; commandprompt#1064's objstore BODY is kept unchanged - objstore becomes `## 38.` where it already sits, and `test_hilbert_cluster.py` moves to 39 -- body order and numbering must agree, because the commandprompt#1023 arm checks inversions as well as gaps - no `(commandprompt#995)` in the heading: none of the other 38 carries an issue reference - CHANGELOG keeps both entries (commandprompt#996 again) ANCHOR RULE, worth stating because @jdatcmd's first attempt at this resolution hit it: lowercase, drop anything outside `[a-z0-9 _-]`, spaces to hyphens. The UNDERSCORES STAY. An anchor that strips them (`commandprompt#38-testobjstoreendpointuserinfopy`) reddens `test_every_in_document_link_in_this_directory_reaches_a_heading` on its own, which is how they caught it. Removal proof on the MERGED tree, restoring from a COPY rather than from git, because this work is uncommitted and `git checkout --` restores to HEAD: CONTROL (resolved merge) 37 passed M objstore back to its ARRIVAL shape 1 failed <- the new arm, alone TESTS.md restored to 149db63b THE ARRIVAL SHAPE IS THE POINT, and it answers @jdatcmd's note that they could not reproduce a single failure by demoting a section. Demoting one that was already numbered leaves a gap and an orphaned contents entry, so all three arms redden. A section that ARRIVES unnumbered, with the contents consistent around it, is invisible to the other two and caught only by this arm. commandprompt#1057 and commandprompt#1064 were both the second kind, which is why neither was caught for a whole cycle. guard leg 341 passed, 870 checks, 0 fail guard_tests 341, cluster_tests 325, both re-derived by collection on the merged tree Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs
Closes #995. #997 closed the caller's half —
s3://u:p@bucket/key. This is the operator's half: userinfo in the endpoint, which the bucket guard cannot see because it is not in the URL at all.Two shapes, and only one was ever caught
Measured on the authority parse before writing anything:
The first has a colon inside the userinfo, so the authority split lands there and the port becomes
atoi("p@host:30829")= 0. The second has no such colon: the real port survives, the@rides along in the host, and the invalid-port refusal never fires.The issue measured the first shape and concluded "refused as invalid host or port". That is true of that shape rather than of the code.
The second shape is why this is a guard and not a message change
Its refusal came from the allow-list, naming the host it could not match, and the hint then told the operator:
A diagnostic that invites widening a security boundary to accommodate a parse bug is worse than a wrong error code. Following it moves the failure from the allow-list to a DNS miss — the operator has loosened their endpoint allow-list and still cannot read the object.
Placement: before the scheme and region demands
Not at the authority parse eighty lines later. Placed there the guard is unreachable whenever no region is configured, so an operator with a userinfo endpoint and no
AWS_REGIONis told about the region instead.Measured — every endpoint arm returned
requires a region optionuntil it moved. That is the unreachable-guard trap the issue itself names, and the same reasoning the bucket guard carries for sitting before the endpoint is resolved at all.The message names the endpoint rather than the URL, because that is the string carrying the userinfo;
userinfo in "s3://bucket/key"would name a URL that has none.Removal proof, on both harnesses
Each run asserts the
.sowas rebuilt and installed (mtime moved) before its verdict is read, and the source is restored to md50de468b1.objstore_userinfo.shtest_objstore_endpoint_userinfo.pyTwo traps walked into while measuring, both already in the record
PGC_SKIP_BUILD=0SKIPS the build.lib.shtests[ -z "${PGC_SKIP_BUILD:-}" ], so any non-empty value means skip — including0, which reads as "do not skip". The first run measured a.sofrom the previous day and reported five failures against code that did not contain the guard. The suite printedPGC_SKIP_BUILD=1: not building AND NOT INSTALLINGon line 5 of its own output and I did not read it; the timestamps did.git checkout -- FILEreverted the uncommitted fix. The removal proof's restore took the file back to HEAD, which had no guard, so the final control failed identically to the mutant. Caught because the restore's md5 assertion did not fire. Fixed by committing before mutating.Tests
Arms in
test/objstore_userinfo.shbeside the existing ones, with a clean-endpoint control in the same run — without it these cannot tell a userinfo refusal from a foreign server that reaches nothing, which is exactly how the issue's first probe wasted a run.And independently in
test/pytest/test_objstore_endpoint_userinfo.py. That file is not a port and not a pair —objstore_endpoint_userinfo.shdoes not exist. It asserts the same properties through the python harness, and nothing in it sources, invokes or reads anything undertest/*.sh. It carries two arms the bash side does not: that the guard fires with no region configured (the placement claim, stated as a test rather than a comment), and that an@in the object key is untouched — without which the arms would also pass on a guard that refused every@anywhere.cluster_tests320 → 325 by collection.One thing for @jdatcmd
The issue said "#706's author should say whether the endpoint message moves." I did not move the existing
invalid host or portmessage — the guard now fires before that path is reached for any@-carrying endpoint, so that message is unchanged and still covers the genuine invalid-port cases. If you would rather the endpoint message readuserinfo in "%s"to match the other three schemes exactly, rather than naming the endpoint, say so and I will change it.🤖 Generated with Claude Code
https://claude.ai/code/session_012RSw4qMHS7ByE7PY8Ns4cs