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}