code wiki / (root) / nx_url_safety_gate.nx

nx_url_safety_gate.nx source

↩ module page · 133 lines · 8173 B

1// nx_url_safety_gate.nx -- REFEREE for nx_url_safety_lib (the ONE SSRF URL ruler, mediaingest R11). 2// 3// THIS GATE IS BUILT AROUND THE ESTATE'S OWN MEASURED FAILURE. Its law: "Four SSRF deny-tests went green 4// while the guard was wholly broken -- a url-decoder missing its SPACE terminator swallowed the request 5// line so EVERY url refused as control bytes. A GUARD THAT REFUSES EVERYTHING PASSES EVERY NEGATIVE TEST. 6// EVERY DENY-GUARD SHIPS WITH A POSITIVE CONTROL and every refusal test asserts WHICH RULE FIRED." 7// So this gate does exactly two things a boolean suite cannot: 8// 1. POSITIVE CONTROLS -- ordinary public URLs that MUST be ALLOWED. A refuse-everything regression fails 9// here and nowhere else. 10// 2. Every refusal asserts the REASON CODE, not merely that it was refused. A guard that refused a public 11// CDN as IPV6_LITERAL would be broken while still "passing" any test that only asked "was it refused". 12// No network, no disk: every input is a literal. 13// license_tier: ORIGINAL 14import "nx_syscalls.nx" 15import "nx_url_safety_lib.nx" 16import "nx_gate_verdict.nx" 17 18const T_HOSTCAP: i64 = 512 19 20func t_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 21 22// assert a url yields an exact reason code 23func t_is(u: *u8, want: i64) -> i64 { 24 if us_reason(u, t_slen(u)) == want { return 1 } 25 return 0 26} 27 28func main() -> i64 { 29 gv_head("=== nx_url_safety_gate -- ONE SSRF ruler, and every refusal names its rule (mediaingest R11) ===" as *u8) 30 let c: *i64 = gv_ctr() 31 var t: i64 = 0 32 33 // ---- POSITIVE CONTROLS FIRST. These are the teeth a refuse-everything guard fails, and they are the 34 // reason this suite is not the one the estate already got burned by. 35 t = t_is("https://example.com/clip.mp4" as *u8, US_OK) 36 gv_check("positive-control-ordinary-https-url-is-ALLOWED" as *u8, t, c) 37 t = t_is("http://cdn.example.org/a/b/c.m3u8?token=abc" as *u8, US_OK) 38 gv_check("positive-control-http-with-path-and-query-is-ALLOWED" as *u8, t, c) 39 t = t_is("https://edge1.stream-provider.net:8443/live/x.ts" as *u8, US_OK) 40 gv_check("positive-control-explicit-port-is-ALLOWED" as *u8, t, c) 41 t = t_is("https://1.2.3.4/x.mp4" as *u8, US_OK) 42 gv_check("positive-control-ordinary-public-IPv4-literal-is-ALLOWED" as *u8, t, c) 43 // 172.x is blocked conservatively, but 17.x must NOT be -- a prefix test that matched "17." would eat 44 // a large public range. This is the tooth that catches an over-broad prefix. 45 t = t_is("https://17.5.5.5/x.mp4" as *u8, US_OK) 46 gv_check("positive-control-17-dot-is-ALLOWED-the-172-rule-must-not-over-match" as *u8, t, c) 47 t = t_is("https://100.64.0.1/x" as *u8, US_OK) 48 gv_check("positive-control-100-64-is-ALLOWED-declared-limit-CGNAT-is-not-blocked" as *u8, t, c) 49 50 // ---- SCHEME ---- 51 t = t_is("ftp://example.com/x" as *u8, US_BAD_SCHEME) 52 gv_check("ftp-scheme-refused-as-BAD-SCHEME" as *u8, t, c) 53 t = t_is("file:///etc/passwd" as *u8, US_BAD_SCHEME) 54 gv_check("file-scheme-refused-as-BAD-SCHEME" as *u8, t, c) 55 t = t_is("gopher://example.com/" as *u8, US_BAD_SCHEME) 56 gv_check("gopher-scheme-refused-as-BAD-SCHEME" as *u8, t, c) 57 t = t_is("example.com/x" as *u8, US_BAD_SCHEME) 58 gv_check("scheme-less-url-refused-as-BAD-SCHEME" as *u8, t, c) 59 t = t_is("https:///nohost" as *u8, US_NO_HOST) 60 gv_check("empty-host-refused-as-NO-HOST" as *u8, t, c) 61 62 // ---- EACH PRIVATE RANGE NAMES ITS OWN RULE ---- 63 t = t_is("http://localhost:8096/api" as *u8, US_LOOPBACK) 64 gv_check("localhost-refused-as-LOOPBACK" as *u8, t, c) 65 t = t_is("http://127.0.0.1/x" as *u8, US_LOOPBACK) 66 gv_check("127-refused-as-LOOPBACK" as *u8, t, c) 67 t = t_is("http://10.0.4.10/admin" as *u8, US_PRIVATE_10) 68 gv_check("10-dot-refused-as-PRIVATE-10" as *u8, t, c) 69 t = t_is("http://192.168.8.240/x" as *u8, US_PRIVATE_192) 70 gv_check("192-168-refused-as-PRIVATE-192-168" as *u8, t, c) 71 // 169.254.169.254 is the cloud metadata endpoint -- the single most-exploited SSRF target there is. 72 t = t_is("http://169.254.169.254/latest/meta-data/" as *u8, US_LINK_LOCAL) 73 gv_check("cloud-metadata-169-254-refused-as-LINK-LOCAL" as *u8, t, c) 74 t = t_is("http://172.17.0.1/x" as *u8, US_PRIVATE_172) 75 gv_check("172-refused-as-PRIVATE-172" as *u8, t, c) 76 t = t_is("http://0.0.0.0:80/x" as *u8, US_ZERO_NET) 77 gv_check("zero-net-refused-as-ZERO-NET" as *u8, t, c) 78 t = t_is("http://[::1]/x" as *u8, US_IPV6_LITERAL) 79 gv_check("ipv6-loopback-literal-refused-as-IPV6-LITERAL" as *u8, t, c) 80 t = t_is("http://[fd00::1]/x" as *u8, US_IPV6_LITERAL) 81 gv_check("ipv6-ULA-literal-refused-as-IPV6-LITERAL" as *u8, t, c) 82 t = t_is("http://nas.local/x" as *u8, US_INTERNAL_TLD) 83 gv_check("dot-local-refused-as-INTERNAL-TLD" as *u8, t, c) 84 t = t_is("http://box.internal/x" as *u8, US_INTERNAL_TLD) 85 gv_check("dot-internal-refused-as-INTERNAL-TLD" as *u8, t, c) 86 t = t_is("http://host.lan/x" as *u8, US_INTERNAL_TLD) 87 gv_check("dot-lan-refused-as-INTERNAL-TLD" as *u8, t, c) 88 89 // ---- THE USERINFO BYPASS. The classic SSRF trick: a public-looking host before an '@' that the real 90 // resolver ignores. A reader that stopped at the first host-shaped run would ALLOW this and send the 91 // request to loopback. The reason must be LOOPBACK, proving the '@' reset ran -- not merely "refused". 92 t = t_is("https://example.com@127.0.0.1/x" as *u8, US_LOOPBACK) 93 gv_check("userinfo-bypass-example-com-AT-127-resolves-to-LOOPBACK-not-allowed" as *u8, t, c) 94 t = t_is("https://trusted.example.org@169.254.169.254/latest/" as *u8, US_LINK_LOCAL) 95 gv_check("userinfo-bypass-to-cloud-metadata-resolves-to-LINK-LOCAL" as *u8, t, c) 96 // and the inverse: an '@' whose RIGHT side is public must still be allowed, so the reset is not a 97 // blanket refusal of every url containing '@'. 98 t = t_is("https://user:pw@example.com/x.mp4" as *u8, US_OK) 99 gv_check("positive-control-credentials-with-a-PUBLIC-host-are-still-ALLOWED" as *u8, t, c) 100 101 // ---- host extraction, directly ---- 102 let hb: *u8 = sys_mmap(T_HOSTCAP) 103 let hn: i64 = us_host_of("https://example.com:8443/a?b=c" as *u8, t_slen("https://example.com:8443/a?b=c" as *u8), hb, T_HOSTCAP) 104 t = 0 105 if hn == 11 { if hb[0] == (101 as u8) { if hb[10] == (109 as u8) { t = 1 } } } 106 gv_check("host-extraction-stops-at-the-port-not-the-path-or-query" as *u8, t, c) 107 t = 0 108 if us_host_of("nope://example.com/" as *u8, 19, hb, T_HOSTCAP) < 0 { t = 1 } 109 gv_check("neg-control-host-extraction-refuses-an-unknown-scheme-with-minus-one" as *u8, t, c) 110 111 // ---- the boolean shim can never disagree with the reason it is derived from ---- 112 t = 0 113 if us_url_safe("https://example.com/x" as *u8, 21) == 1 { 114 if us_url_safe("http://127.0.0.1/x" as *u8, 18) == 0 { t = 1 } 115 } 116 gv_check("boolean-shim-agrees-with-the-reason-in-both-directions" as *u8, t, c) 117 t = 0 118 if us_reason_is_valid(US_REASON_N) == 0 { if us_reason_is_valid(US_OK) == 1 { t = 1 } } 119 gv_check("neg-control-reason-bounds-reject-the-sentinel-and-accept-a-real-reason" as *u8, t, c) 120 121 // ---- ANTI-VACUITY: the suite must contain BOTH outcomes. A guard stuck at ALLOW or stuck at REFUSE 122 // fails here, which is the failure mode the estate actually shipped once. 123 var allows: i64 = 0 124 var refuses: i64 = 0 125 if us_url_safe("https://example.com/x" as *u8, 21) == 1 { allows = 1 } 126 if us_url_safe("http://10.0.0.1/x" as *u8, 17) == 0 { refuses = 1 } 127 t = 0 128 if allows == 1 { if refuses == 1 { t = 1 } } 129 gv_check("anti-vacuity-the-guard-both-ALLOWS-and-REFUSES-it-is-not-stuck-either-way" as *u8, t, c) 130 131 return gv_verdict("nx_url_safety_gate" as *u8, c, 132 "syntactic URL guard only, on literal inputs with no network and no disk: it does NOT resolve DNS, so a public hostname whose A record points into a private range (DNS rebinding) is ALLOWED here BY DESIGN and must be refused at connect time by the fetcher; 172.x is blocked whole rather than only 172.16-31, and CGNAT 100.64/10 is not blocked at all -- both are declared limits, not oversights" as *u8) 133}