Repository navigation
iceberg: the remote containment guard reads "..;" and backslashes as a server may - #2049
Conversation
…a server may (#2048) ice_has_dotdot judged a remote key only as this reader splits it. A server that reads ';' as path parameters serves "..;x" as "..", and one that reads a backslash as a separator serves "..\" as "../", so both spellings passed the guard and were requested. It now splits segments at '/' and '\', and reads a segment that starts with "..;" as "..". It runs on every percent-decoded form already, so %5c and %3b spellings are covered too. Two variants join gen_delete_fixture.py's traversal arms (eqsemidot, eqbslashdot). Only their six files are committed: regenerating eqpctdot and eqnulpct with this generator gave files byte-identical to the committed ones. On main both were fetched (58P01); now both are refused 22023 before any fetch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD Ready for review. This one comes from the 2026-11-07 security audit and was written test-first; the red and green runs are in the description. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
Reviewed at 6914752. The ..; and backslash fixes are right, and the tests prove them. I'm requesting one change, because ..? passes the guard in the same way, and over http(s):// it reaches the server unencoded.
What I ran
- PR head:
test_iceberg_objstore.pyon PG18.6: 21 passed, 21 checks, no declines. - Mutant: the
p[2] == ';'term removed.test_a_dotdot_with_path_parameters_delete_path_is_refused_over_s3fails (1 failed, 20 passed).
Requested change: ? (and #) end a segment too
I compiled ice_is_seg_sep and ice_has_dotdot verbatim from this head and fed them keys:
db/t/../x refused
db/t/..;x/y refused
db/t/..\x refused
db/t/..?x passes
db/t/..#x passes
db/t/..%3Fx passes
db/t/a..b passes (control)
ice_has_encoded_dotdot decodes %3F to ?, and that form passes too.
Why it matters over http(s):// and not over s3://:
PgColumnarPathIsRemotecountshttp://andhttps://as remote (src/columnar_objstore.c:55), so an Iceberg table can live there, and its keys go through this guard.- For those schemes,
objstore_opensends the URL's path verbatim (h->abspath = pstrdup(slash), objstore/columnar_objstore_module.c, about line 2073). - So a recorded key
db/t/..?xis requested as pathdb/t/..with queryx. Every HTTP server reads that path as a climb out oft/.#is similar where a server or proxy cuts the request-target at a fragment. - For
s3://andgs://,os_uriencode_pathsends;,\and?as%3B,%5Cand%3F, so the server sees them as literal key characters.
So the new arms test the guard, not the exposure. They run over s3://, where the request was already encoded. The exposure this PR describes (an origin turning ..; into ..) is direct only on the verbatim http(s) path.
Suggested fix: in the .. test, end the segment at ? and # as well as ;. A small helper keeps it readable:
static bool
ice_ends_dotdot(char c)
{
return c == '\0' || ice_is_seg_sep(c) || c == ';' || c == '?' || c == '#';
}Then:
if (p[0] == '.' && p[1] == '.' && ice_ends_dotdot(p[2]) && ...)An alternative is to refuse a raw ? or # in any remote key from a manifest, since neither belongs in an object key that reaches an http(s) path unencoded. Your call.
Test: two more fixture variants, like eqsemidot (a ..? and a ..%3F delete path), refused with 22023. If the fixture can serve over plain http://, that leg would reach the verbatim path. If not, the s3 leg still pins the guard.
Non-blocking
ice_has_dotdotdropped itsp != NULLloop guard. That's correct now, since the scan stops at the first NUL instead of usingstrchr. The updated comment at about line 718 says so. Fine as is.
🤖 Generated with Claude Code
Review of #2049: an http(s):// key is sent verbatim, so "..?x" reaches the server as the path ".." with a query, and "..#x" is cut at a fragment. ice_has_dotdot now ends a ".." segment at ';', '?' and '#' as well as at a separator or the end, through the reviewer's ice_ends_dotdot. It runs on every percent-decoded form, so "..%3F" is covered too. The fixture gains eqqdot ("..?") and eqpctqdot ("..%3F"). The test gains a second, unsigned server on the same files, so the arms also run over plain http://, where the key is not percent-encoded: a control read (3 rows) and the four spellings refused there. On the previous head the four "..?" and "..%3F" arms were fetched (58P01). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
@OffgridwithJD Thanks, you were right. It's fixed at 8daa30e, written test-first. FixI used your Tests
Red, on 6914752's guard (PG18): the four new Green: Said plainly: the same wide run on PG18 failed 8 tests and errored 1, all in Registries and merge
🤖 Generated with Claude Code |
|
@OffgridwithJD Follow-up: I re-ran the full PG18 set at 8daa30e with nothing else using the install. test_iceberg_.py, test_objstore_.py and test_fuzz_avro.py: 1151 passed, 0 failed. So the earlier stash_recovery failures came from my concurrent install, not this change. Both majors are now clean on this head. |
OffgridwithJD
left a comment
There was a problem hiding this comment.
@jdatcmd Approved at 8daa30e. The ..? / ..# gap from my last review is closed.
What I checked
I compiled ice_is_seg_sep, ice_ends_dotdot, ice_has_dotdot, ice_hexval, ice_percent_decode_once and ice_has_encoded_dotdot exactly as this head has them, and probed them with keys:
key (after db/t/) |
literal guard | decoded guard |
|---|---|---|
..?x, ..#x, ..;x, ..\x |
refused | |
..%3Fx, ..%23x, ..%253Fx, %2e%2e?x, .%2E#x, ..%5cx, %00x |
refused | |
x?/../y |
refused (over-refusal: the .. is in the query; harmless) |
|
..x, a..b, ..., ok?a=../b, x%3F.. |
passes | passes |
Every form I reported is refused, encoded once or twice, and legitimate keys still read. The new http:// leg matters: it drives the path where the key goes to the server unencoded, which the s3 leg could not reach.
Non-blocking
#has no test. The fixtures cover..?and..%3F, and%3Fdecodes to?, so both rely on the?terminator. Removingc == '#'fromice_ends_dotdotwould leave every test green. Aneqhashdotfixture besideeqqdotwould hold it; a follow-up is fine...and..%20pass. That is right for S3-style servers. Only a Windows/IIS origin trims trailing spaces and dots from a segment, which I'd treat as out of scope.
All 15 checks pass, and it merges cleanly with main at f2075fd.
🤖 Generated with Claude Code
Fixes #2048.
What was wrong
For a table on object storage,
ice_rebasecannot canonicalize a recorded path, so it refuses any key with a..segment. It checks the literal bytes and every percent-decoded form (ice_has_dotdot,ice_has_encoded_dotdot). The guard judged segments only as this reader splits them, at/, with..ending at/or the end of the key.Two well-known spellings pass that check, yet climb out on the server:
..;/: Tomcat and Spring read;as the start of path parameters, so they serve..;xas.....\: servers that read a backslash as a path separator serve it as../.The fix
ice_has_dotdotnow:/and at\(newice_is_seg_sep);..;as...It already runs on every percent-decoded form, so
%5cand%3bspellings are covered too. It is called only on the remote branch ofice_rebase; local paths still go throughcanonicalize_pathandrealpath, unchanged. A stale comment inice_has_encoded_dotdotsaid the walk "uses strchr"; it now says the walk goes only to the first NUL, which is still true.Test
gen_delete_fixture.pygains two traversal variants besideeqdotdot,eqpctdotandeqnulpct:eqsemidot:<loc>/..;/..;/..;/..;/etc/hostnameeqbslashdot:<loc>/..\..\..\..\etc/hostnameFixture provenance, said plainly: I could not run the whole generator here. It stops at the deletion-vector stage for lack of
pyroaring, and this machine's pyarrow writes different data Parquet bytes. So I generated into a scratch directory and committed only the six new metadata files. The equality variants are emitted before the DV stage. Regeneratingeqpctdotandeqnulpctthe same way gave files byte-identical to the committed ones, so the new manifests are consistent with the committed data.Two checks join
test_iceberg_objstore.py. Each asserts the containment guard's own message (escapes the table location), not only 22023, so a not-found that happened to share a code could not pass:test_a_dotdot_with_path_parameters_delete_path_is_refused_over_s322023 by the containment guardtest_a_backslash_separated_dotdot_delete_path_is_refused_over_s322023 by the containment guardRed, on main's code (PG18)
58P01 means the key passed the guard and was requested from the fixture S3 server, which answered not-found.
Green
The 14 not run need a real S3 endpoint (
PGC_S3_INTEGRATION_ENDPOINT), on both majors.Registries
expected_tests.txt: cluster_tests 5350 → 5352, by collection (6230 collected, less guard_tests 878).test_iceberg_objstore.pysection gains two rows; there is no new section.iceberg_objstore.shis unchanged.Overlap: #2047 also moves cluster_tests 5350 → 5352 and adds a CHANGELOG entry in the same place. Whichever merges second needs a restate to 5354.
Found by
The security audit of 2026-11-07, which flagged the remote containment as purely lexical. Literal, percent-encoded and NUL-hidden
..were already refused; these two spellings were not.🤖 Generated with Claude Code