From 69147526e85adec6b8f12369a812cce2daee8492 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 17:32:20 +0000 Subject: [PATCH 1/2] iceberg: the remote containment guard reads "..;" and backslashes as 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 --- CHANGELOG.md | 9 ++++++ src/columnar_iceberg.c | 26 +++++++++++++----- test/fixtures/iceberg/gen_delete_fixture.py | 12 ++++++++ .../metadata/delete-manifest-eqbslashdot.avro | Bin 0 -> 656 bytes .../t/metadata/delete-manifest-eqsemidot.avro | Bin 0 -> 660 bytes .../db/t/metadata/eqbslashdot.metadata.json | 1 + .../db/t/metadata/eqsemidot.metadata.json | 1 + .../t/metadata/manifest-list-eqbslashdot.avro | Bin 0 -> 913 bytes .../t/metadata/manifest-list-eqsemidot.avro | Bin 0 -> 911 bytes test/pytest/TESTS.md | 2 ++ test/pytest/expected_tests.txt | 5 +++- test/pytest/test_iceberg_objstore.py | 21 ++++++++++++++ 12 files changed, 69 insertions(+), 8 deletions(-) create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqbslashdot.avro create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqsemidot.avro create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/eqbslashdot.metadata.json create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/eqsemidot.metadata.json create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/manifest-list-eqbslashdot.avro create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/manifest-list-eqsemidot.avro diff --git a/CHANGELOG.md b/CHANGELOG.md index f858422ba..9976e9224 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -572,6 +572,15 @@ true until the next version shipped. ### Fixed +- An Iceberg table on object storage can no longer name a file outside its + location by spelling `..` in a form that only the server reads as `..`. The + remote containment guard already refused a literal `..` and a percent-encoded + one. It now also refuses a segment that starts with `..;`, which servers that + read `;` as the start of path parameters (Tomcat, Spring) serve as `..`, and + it treats a backslash as a separator, as some servers do. Such a path is + refused with `escapes the table location` (22023) before any request is + sent. + - A read of a columnar table inside a plpgsql EXCEPTION block that rolls back no longer loses the enclosing statement's rows (#1360). To see the rows the enclosing `INSERT` has buffered, as on heap, the read writes them out. diff --git a/src/columnar_iceberg.c b/src/columnar_iceberg.c index 6800a5975..554750fa2 100644 --- a/src/columnar_iceberg.c +++ b/src/columnar_iceberg.c @@ -609,20 +609,32 @@ ice_path_eq(const char *a, const char *b) * key is literal (no symlinks, no realpath), so a recorded ".." cannot be * collapsed the way canonicalize_path does for a local path; reject it, so a * recorded key cannot reference outside the table prefix on a server that - * normalizes "..". */ + * normalizes "..". + * + * The key is judged as the server behind the URL might read it, not only as + * this reader does. A backslash is a separator to some origins, so it splits + * segments here too. A ';' starts path parameters on others (Tomcat, Spring), + * which serve "..;x" as "..", so a segment that starts with "..;" is "..". */ +static bool +ice_is_seg_sep(char c) +{ + return c == '/' || c == '\\'; +} + static bool ice_has_dotdot(const char *s) { const char *p = s; - while (p != NULL && *p != '\0') + while (*p != '\0') { if (p[0] == '.' && p[1] == '.' && - (p[2] == '\0' || p[2] == '/') && - (p == s || p[-1] == '/')) + (p[2] == '\0' || ice_is_seg_sep(p[2]) || p[2] == ';') && + (p == s || ice_is_seg_sep(p[-1]))) return true; - p = strchr(p, '/'); - if (p != NULL) + while (*p != '\0' && !ice_is_seg_sep(*p)) + p++; + if (*p != '\0') p++; } return false; @@ -706,7 +718,7 @@ ice_has_encoded_dotdot(const char *s) /* * An encoded NUL truncates every check below this point. The next pass - * measures with strlen and ice_has_dotdot walks with strchr, so both + * measures with strlen and ice_has_dotdot walks only to the first NUL, so both * stop at that byte and never reach the "../" behind it: a key of * "%00%2e%2e/%2e%2e/etc/hostname" decoded to a NUL followed by the * traversal, and this function returned false. The literal-".." guard diff --git a/test/fixtures/iceberg/gen_delete_fixture.py b/test/fixtures/iceberg/gen_delete_fixture.py index acc6ee1e0..98ec48491 100755 --- a/test/fixtures/iceberg/gen_delete_fixture.py +++ b/test/fixtures/iceberg/gen_delete_fixture.py @@ -542,6 +542,18 @@ def many_region(i): # "%2e%2e" -> ".." before serving would escape the table location. The remote # guard must decode-and-reject it (harmless on a local root: "%2e%2e" is just # a directory name that does not exist, so realpath fails rather than escapes) +emit_eq_variant("eqsemidot", [(2, EQ_SEQ, + f"{LOC}/..;/..;/..;/..;/etc/hostname", + [1], 1)]) +# the same climb-out spelled "..;": an origin that reads ";" as the start of +# path parameters (Tomcat, Spring) serves "..;x" as "..", so the remote guard +# must read a segment that starts with "..;" as "..". +emit_eq_variant("eqbslashdot", [(2, EQ_SEQ, + f"{LOC}/..\\..\\..\\..\\etc/hostname", + [1], 1)]) +# the same climb-out with a backslash between the segments: an origin that +# reads a backslash as a path separator serves it as "/", so the remote guard +# must read a backslash as a separator. # ---- audit arms (multi-agent adversarial audit of 4b) ---------------------- diff --git a/test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqbslashdot.avro b/test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqbslashdot.avro new file mode 100644 index 0000000000000000000000000000000000000000..f07e47124f3d82a29c16efe272b1250942af102f GIT binary patch literal 656 zcmaJf zVnQ#rdsIY$b%I^}Wje(&eOClP{Z!_x>hXZO4Ma<|;2d)@v9=@rOvj$&|p$k&jN z%h4upJ!g!etyr~^T&Fk{7^Vt_Y&Lt{UqH#$K4ShwR}Z&Ok1u!6$Syk4ZbE+v%hOf%h7m1nz2EK)YZo13*+62E_-cCpx~u8&}sNj zuPQU}oJQ##rXe~!^h9KSh*tvFT~x%Nldhhu7FhaVMUU_tbmu9TzV1KX4tq)(^(S!c zc+nvA96iM+M}^VYaGmlvc)$1a;FAbJwBcz4o3q=EzT7UiXx$#sfTfnlm($Y!(K-T{VvhSpkYZ4OS`MTS#Z4)B!V&r{htosj6=j z)nX1Fv$>E^eV7Z7+z0q_)rBm@qJkPdk51@Cx*m@Xz!LOgAD5;LTk24tFgo)VSSIRh zjk?gdFU^cb2$wqvI*f;937SEsOO=D$WxMuD1mph{P|ks8UQ_D}gRQ(>{S98V)@RI= ze?*5n;W6~Hev3W@93)`K)7M}V$C=v$;@$AuIDHG6IKS~%>(hERnc={Wbtz@kUddx@ zt3R3%LQq*^7hy2Mg9drRbAm(xQcjVQYMV3@Jgs!!PNHZqcz^l&`2Ku7Pr{+C9!1G> e{1Epvs2jYsn7}gklKO%hZ0wTZaK|n}mumY^5=xHN6pQilSC(V4fvGErx1 z)P=@08jm`HjC?pVqU<3 5352 for test_iceberg_objstore.py's remote ".." spellings (security audit, +# 2026-11-07): test_a_dotdot_with_path_parameters_delete_path_is_refused_over_s3 and +# test_a_backslash_separated_dotdot_delete_path_is_refused_over_s3, by collection. +cluster_tests 5352 diff --git a/test/pytest/test_iceberg_objstore.py b/test/pytest/test_iceberg_objstore.py index c45e1666d..c7ee4ecf0 100644 --- a/test/pytest/test_iceberg_objstore.py +++ b/test/pytest/test_iceberg_objstore.py @@ -195,6 +195,12 @@ def iob(pgc_cluster): for key, meta in (("escape", "eqescape"), ("dotdot", "eqdotdot"), ("pctdot", "eqpctdot"), ("nulpct", "eqnulpct")): r[key] = _sqlstate(dsn, f"SELECT * FROM {scan.format(MDROOT, meta)}") + # Spellings of ".." that an origin, not this reader, turns into a climb: + # "..;" (path parameters) and a backslash separator. Refused by the + # containment guard, before any fetch, as the forms above are. + for key, meta in (("semidot", "eqsemidot"), ("bslashdot", "eqbslashdot")): + r[key] = _refusal(dsn, f"SELECT * FROM {scan.format(MDROOT, meta)}", + "escapes the table location", " by the containment guard") r["after refusal"] = _survived(dsn, log, before) _restart(cluster, datadir, log, dict(aws, AWS_ENDPOINT_URL=f"http://127.0.0.2:{s3_port}")) @@ -329,6 +335,21 @@ def test_a_percent_encoded_dotdot_behind_an_encoded_nul_is_refused_over_s3(iob, "a percent-encoded dotdot behind an encoded NUL is refused over s3 (22023)") +def test_a_dotdot_with_path_parameters_delete_path_is_refused_over_s3(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["semidot"], "22023 by the containment guard", + "a delete path climbing out with \"..;\" segments is refused over s3 by the containment guard") + + +def test_a_backslash_separated_dotdot_delete_path_is_refused_over_s3(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["bslashdot"], "22023 by the containment guard", + "a delete path climbing out with backslash-separated \"..\" segments is refused over s3 " + "by the containment guard") + + def test_the_backend_is_still_up_after_the_s3_refusal(iob, expect): if _declined(iob, expect): return From b5b26b96d4db3f767c6f77e8c08a784d9a3f1110 Mon Sep 17 00:00:00 2001 From: "Joshua D. Drake" Date: Mon, 9 Nov 2026 18:50:17 +0000 Subject: [PATCH 2/2] iceberg: "?" and "#" end a ".." segment in the remote guard (#2048) 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 --- src/columnar_iceberg.c | 12 ++- test/fixtures/iceberg/gen_delete_fixture.py | 11 +++ .../t/metadata/delete-manifest-eqpctqdot.avro | Bin 0 -> 669 bytes .../db/t/metadata/delete-manifest-eqqdot.avro | Bin 0 -> 660 bytes .../db/t/metadata/eqpctqdot.metadata.json | 1 + .../db/t/metadata/eqqdot.metadata.json | 1 + .../t/metadata/manifest-list-eqpctqdot.avro | Bin 0 -> 911 bytes .../db/t/metadata/manifest-list-eqqdot.avro | Bin 0 -> 908 bytes test/pytest/test_iceberg_objstore.py | 76 +++++++++++++++++- 9 files changed, 98 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqpctqdot.avro create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqqdot.avro create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/eqpctqdot.metadata.json create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/eqqdot.metadata.json create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/manifest-list-eqpctqdot.avro create mode 100644 test/fixtures/iceberg/warehouse_del/db/t/metadata/manifest-list-eqqdot.avro diff --git a/src/columnar_iceberg.c b/src/columnar_iceberg.c index 554750fa2..e1c470103 100644 --- a/src/columnar_iceberg.c +++ b/src/columnar_iceberg.c @@ -614,13 +614,21 @@ ice_path_eq(const char *a, const char *b) * The key is judged as the server behind the URL might read it, not only as * this reader does. A backslash is a separator to some origins, so it splits * segments here too. A ';' starts path parameters on others (Tomcat, Spring), - * which serve "..;x" as "..", so a segment that starts with "..;" is "..". */ + * which serve "..;x" as "..". An http(s):// key is sent verbatim, so a '?' + * starts the query and a '#' the fragment: "..?x" reaches the server as the + * path "..". So ';', '?' and '#' each end a ".." segment. */ static bool ice_is_seg_sep(char c) { return c == '/' || c == '\\'; } +static bool +ice_ends_dotdot(char c) +{ + return c == '\0' || ice_is_seg_sep(c) || c == ';' || c == '?' || c == '#'; +} + static bool ice_has_dotdot(const char *s) { @@ -629,7 +637,7 @@ ice_has_dotdot(const char *s) while (*p != '\0') { if (p[0] == '.' && p[1] == '.' && - (p[2] == '\0' || ice_is_seg_sep(p[2]) || p[2] == ';') && + ice_ends_dotdot(p[2]) && (p == s || ice_is_seg_sep(p[-1]))) return true; while (*p != '\0' && !ice_is_seg_sep(*p)) diff --git a/test/fixtures/iceberg/gen_delete_fixture.py b/test/fixtures/iceberg/gen_delete_fixture.py index 98ec48491..a43db83f2 100755 --- a/test/fixtures/iceberg/gen_delete_fixture.py +++ b/test/fixtures/iceberg/gen_delete_fixture.py @@ -554,6 +554,17 @@ def many_region(i): # the same climb-out with a backslash between the segments: an origin that # reads a backslash as a path separator serves it as "/", so the remote guard # must read a backslash as a separator. +emit_eq_variant("eqqdot", [(2, EQ_SEQ, + f"{LOC}/..?/..?/..?/..?/etc/hostname", + [1], 1)]) +# the same climb-out with "?" after each "..": an http(s):// key is sent +# verbatim, so the server reads the path as ending at "..", with the rest as +# a query. "?" (and "#") end a ".." segment in the remote guard. +emit_eq_variant("eqpctqdot", [(2, EQ_SEQ, + f"{LOC}/..%3F/..%3F/..%3F/..%3F/etc/hostname", + [1], 1)]) +# the same with the "?" percent-encoded: a server or proxy that decodes it +# first sees "..?"; the guard checks every decoded form. # ---- audit arms (multi-agent adversarial audit of 4b) ---------------------- diff --git a/test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqpctqdot.avro b/test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqpctqdot.avro new file mode 100644 index 0000000000000000000000000000000000000000..efe95d042db964cd33768db1f4e229aa0cd0a1cc GIT binary patch literal 669 zcmaKq%}N6?6ouQNU>D*_T$g~m(k$F{=R&vspsP|sCb`o_@-rrPgs~LF=kdXO43jBs zXWHTrW|4fk_uO;xkiYc@SNy}fNf1jg{A;)(SeHPiBjQ0gFUfdBEH}-L;Z_w8kby;C zr;|c~mLbx!)s70tu?p#QTS-|Y>+z_Czgp(kKDT?0V zm;Lm!?0oRuPh(FhrD#f8E(B9CPYIgJoFbh}u5a#7`~d}Bx`63PT|C}CKfT_)WWQ%w JmL2x#{Q;WW#wGv& literal 0 HcmV?d00001 diff --git a/test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqqdot.avro b/test/fixtures/iceberg/warehouse_del/db/t/metadata/delete-manifest-eqqdot.avro new file mode 100644 index 0000000000000000000000000000000000000000..88d5cf6ca18ede7d2169067f8ac2391b90d3e5a6 GIT binary patch literal 660 zcmaJ<%Sr<=6m3JnF2t3%E?Jjm-;KC%?E_tv5;Do1Hj>Af+!4l7@H_lMKf`2-?Mzz? z$tL&QlXK6>L-95o-H4CiXHl-fim&m8Vp9Q~PiO$?gQC+3b;7ncRybWkL=Fx^lPjeL zqhdlYHhWY=fpyH)KPK&vZl{wD1m0_K61b~v0ilCkP*)q1FN}99y6m+nfr6LXK&Rn9 zy{gQ>a~h?0n1<-^&=ZmQAzlewcTo|8PP%%sT43pe6+Oap(4D7T`nvykJM1ZG)Stk$ z<3)qebMzFS92G`m!*$Bz;QijugHIv^(T1lHY|d^s`f|J6rhVQ12I&>ZagJheyvX;E zkjqiXQ_mS=Xe(B&B-bfU1%|1DA)C!^dk0XmwU3y8($(YL^V9473;DSqgdEckegU&v B#D@R? literal 0 HcmV?d00001 diff --git a/test/fixtures/iceberg/warehouse_del/db/t/metadata/eqpctqdot.metadata.json b/test/fixtures/iceberg/warehouse_del/db/t/metadata/eqpctqdot.metadata.json new file mode 100644 index 000000000..323ee8f3c --- /dev/null +++ b/test/fixtures/iceberg/warehouse_del/db/t/metadata/eqpctqdot.metadata.json @@ -0,0 +1 @@ +{"format-version": 2, "location": "file:///tmp/pgc_ice_del/db/t", "current-schema-id": 0, "schemas": [{"schema-id": 0, "type": "struct", "fields": [{"id": 1, "name": "id", "required": false, "type": "long"}, {"id": 2, "name": "region", "required": false, "type": "string"}, {"id": 3, "name": "amount", "required": false, "type": "int"}, {"id": 8, "name": "created", "required": false, "type": "timestamp"}, {"id": 9, "name": "extra", "required": false, "type": "long"}]}], "partition-specs": [{"spec-id": 0, "fields": []}, {"spec-id": 1, "fields": [{"name": "region", "transform": "identity", "source-id": 2, "field-id": 1000}]}], "default-spec-id": 0, "current-snapshot-id": 7766554433221100, "snapshots": [{"snapshot-id": 7766554433221100, "sequence-number": 6, "timestamp-ms": 0, "manifest-list": "file:///tmp/pgc_ice_del/db/t/metadata/manifest-list-eqpctqdot.avro", "summary": {"operation": "overwrite"}, "schema-id": 0}]} \ No newline at end of file diff --git a/test/fixtures/iceberg/warehouse_del/db/t/metadata/eqqdot.metadata.json b/test/fixtures/iceberg/warehouse_del/db/t/metadata/eqqdot.metadata.json new file mode 100644 index 000000000..85f190f6e --- /dev/null +++ b/test/fixtures/iceberg/warehouse_del/db/t/metadata/eqqdot.metadata.json @@ -0,0 +1 @@ +{"format-version": 2, "location": "file:///tmp/pgc_ice_del/db/t", "current-schema-id": 0, "schemas": [{"schema-id": 0, "type": "struct", "fields": [{"id": 1, "name": "id", "required": false, "type": "long"}, {"id": 2, "name": "region", "required": false, "type": "string"}, {"id": 3, "name": "amount", "required": false, "type": "int"}, {"id": 8, "name": "created", "required": false, "type": "timestamp"}, {"id": 9, "name": "extra", "required": false, "type": "long"}]}], "partition-specs": [{"spec-id": 0, "fields": []}, {"spec-id": 1, "fields": [{"name": "region", "transform": "identity", "source-id": 2, "field-id": 1000}]}], "default-spec-id": 0, "current-snapshot-id": 7766554433221100, "snapshots": [{"snapshot-id": 7766554433221100, "sequence-number": 6, "timestamp-ms": 0, "manifest-list": "file:///tmp/pgc_ice_del/db/t/metadata/manifest-list-eqqdot.avro", "summary": {"operation": "overwrite"}, "schema-id": 0}]} \ No newline at end of file diff --git a/test/fixtures/iceberg/warehouse_del/db/t/metadata/manifest-list-eqpctqdot.avro b/test/fixtures/iceberg/warehouse_del/db/t/metadata/manifest-list-eqpctqdot.avro new file mode 100644 index 0000000000000000000000000000000000000000..9eb6fab3ffa9f628d13bff076111850cc9737fd4 GIT binary patch literal 911 zcmb7?y-ve06or$B0acY)5DQC`xvlrU1P_2v0H;4w!K~8>N}1s=iTF ziy3@{tVJ1LwAK=SX7qS!=71Zc?bVARw^(Z|6OVIOuT$(m)sY8Ln=-gXixu~-> z>O$kbG&33@T<#?3FdmjAXa>11RRM07?b<65jQ>+WIR~D5O|4%TY~}6hZ}6(MK4+%< zBRbp(kD;ISTl6X5AOS<3z6P5(&fFdl?}p#T>08jm`HjC?pVqU<30H;4w!K~8>N}1s=iTF ziy3@{tVJ1LwAK=SX7qS!=71Zc?bVARw^(Z|6OVIOuT$(m)sY8Ln=-gXixu~-> z>O$kbG&33@T<#?3FdmjAXa>11RRM07?b<65jQ>+WIR~D5O|4%TY~}6hZ}6(MK4+%< zBRbp(kD;ISTl6X5AOS<3z6P5(&fFdl?}p#T>08jm`HjC?pVqU<3uP literal 0 HcmV?d00001 diff --git a/test/pytest/test_iceberg_objstore.py b/test/pytest/test_iceberg_objstore.py index c7ee4ecf0..f9a254b17 100644 --- a/test/pytest/test_iceberg_objstore.py +++ b/test/pytest/test_iceberg_objstore.py @@ -153,6 +153,18 @@ def iob(pgc_cluster): if out.is_file() and "READY" in out.read_text(errors="replace"): break time.sleep(0.1) + # The same files over plain http://, unsigned. An http(s) key is sent verbatim, + # not percent-encoded as an s3:// key is, so this leg reaches the server with + # the recorded bytes. + http_port = pick_port(28) + hout = work / "http_server.out" + hsrv = subprocess.Popen([sys.executable, str(TEST / "objstore_http_server.py"), "--dir", str(work), + "--port", str(http_port), "--log", str(work / "http.log")], + stdout=open(hout, "w"), stderr=subprocess.STDOUT) + for _ in range(50): + if hout.is_file() and "READY" in hout.read_text(errors="replace"): + break + time.sleep(0.1) datadir = work / "data" datadir.mkdir() @@ -198,9 +210,16 @@ def iob(pgc_cluster): # Spellings of ".." that an origin, not this reader, turns into a climb: # "..;" (path parameters) and a backslash separator. Refused by the # containment guard, before any fetch, as the forms above are. - for key, meta in (("semidot", "eqsemidot"), ("bslashdot", "eqbslashdot")): + for key, meta in (("semidot", "eqsemidot"), ("bslashdot", "eqbslashdot"), + ("qdot", "eqqdot"), ("pctqdot", "eqpctqdot")): r[key] = _refusal(dsn, f"SELECT * FROM {scan.format(MDROOT, meta)}", "escapes the table location", " by the containment guard") + http_root = f"http://127.0.0.1:{http_port}/{BUCKET}/db/t/metadata" + r["http apply"] = _value(dsn, f"SELECT count(*) FROM {scan.format(http_root, 'apply')}") + for key, meta in (("semidot", "eqsemidot"), ("bslashdot", "eqbslashdot"), + ("qdot", "eqqdot"), ("pctqdot", "eqpctqdot")): + r[f"http {key}"] = _refusal(dsn, f"SELECT * FROM {scan.format(http_root, meta)}", + "escapes the table location", " by the containment guard") r["after refusal"] = _survived(dsn, log, before) _restart(cluster, datadir, log, dict(aws, AWS_ENDPOINT_URL=f"http://127.0.0.2:{s3_port}")) @@ -212,6 +231,8 @@ def iob(pgc_cluster): _pg_ctl(cluster, ["-D", str(datadir), "-m", "immediate", "-w", "stop"], {}) srv.kill() srv.wait() + hsrv.kill() + hsrv.wait() shutil.rmtree(work, ignore_errors=True) @@ -350,6 +371,59 @@ def test_a_backslash_separated_dotdot_delete_path_is_refused_over_s3(iob, expect "by the containment guard") +def test_a_dotdot_ended_by_a_query_mark_delete_path_is_refused_over_s3(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["qdot"], "22023 by the containment guard", + "a delete path climbing out with \"..?\" segments is refused over s3 by the containment guard") + + +def test_a_dotdot_ended_by_an_encoded_query_mark_delete_path_is_refused_over_s3(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["pctqdot"], "22023 by the containment guard", + "a delete path climbing out with \"..%3F\" segments is refused over s3 by the containment guard") + + +def test_an_iceberg_table_reads_over_plain_http(iob, expect): + """The premise of the http(s) arms below: this leg reaches the reader at all.""" + if _declined(iob, expect): + return + expect.text(iob["http apply"], "3", "an Iceberg table with position deletes reads over plain http (3 rows)") + + +def test_a_dotdot_with_path_parameters_delete_path_is_refused_over_plain_http(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["http semidot"], "22023 by the containment guard", + "a delete path climbing out with \"..;\" segments is refused over plain http, where the key " + "is sent verbatim, by the containment guard") + + +def test_a_dotdot_backslash_separated_delete_path_is_refused_over_plain_http(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["http bslashdot"], "22023 by the containment guard", + "a delete path climbing out with backslash-separated \"..\" segments is refused over plain http, where the key " + "is sent verbatim, by the containment guard") + + +def test_a_dotdot_ended_by_a_query_mark_delete_path_is_refused_over_plain_http(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["http qdot"], "22023 by the containment guard", + "a delete path climbing out with \"..?\" segments is refused over plain http, where the key " + "is sent verbatim, by the containment guard") + + +def test_a_dotdot_ended_by_an_encoded_query_mark_delete_path_is_refused_over_plain_http(iob, expect): + if _declined(iob, expect): + return + expect.text(iob["http pctqdot"], "22023 by the containment guard", + "a delete path climbing out with \"..%3F\" segments is refused over plain http, where the key " + "is sent verbatim, by the containment guard") + + def test_the_backend_is_still_up_after_the_s3_refusal(iob, expect): if _declined(iob, expect): return