nx_url_safety_lib.nx source
↩ module page · 152 lines · 7494 B
1// nx_url_safety_lib.nx -- THE ONE SSRF URL-SAFETY RULER (/compare/mediaingest R11 ge_edge_push).
2//
3// WHY THIS EXISTS. Measured 2026-09-01, corpus_complete=1 over 23,424 sources: the estate has TWO copies of
4// this decision -- csd_url_safe (nx_clean_serve_daemon.nx:101) and ncv_url_safe (nx_cleanview.nx:44). They
5// are byte-for-byte identical logic today, differing only in their function-name prefix, which is exactly
6// the duplicate-ruler defect: they agree by the luck of having been copied, and the next edit to one will
7// not reach the other. For a SECURITY decision that is not a style problem -- it is how one surface quietly
8// keeps admitting a host the other learned to refuse.
9// ⇒ ONE ruler, composed by every caller. WHEN TWO ORGANS MUST AGREE, MAKE DISAGREEMENT IMPOSSIBLE BY
10// CONSTRUCTION RATHER THAN BY REVIEW DISCIPLINE.
11//
12// WHY IT RETURNS A REASON AND NOT A BOOLEAN. This estate has already paid for a boolean here. Its own law:
13// "Four SSRF deny-tests went green while the guard was wholly broken (a url-decoder missing its SPACE
14// terminator swallowed the request line so EVERY url refused as control bytes)." A guard that refuses
15// everything passes every negative test. So:
16// - us_reason() names WHICH RULE FIRED, so a refusal test can assert the rule and not merely the refusal;
17// - us_url_safe() is the 0/1 shim, defined AS us_reason()==US_OK, so the boolean can never disagree with
18// the reason it is derived from;
19// - and any gate over this MUST carry POSITIVE CONTROLS -- inputs that must be ALLOWED -- or a
20// refuse-everything regression scores full marks.
21//
22// SCOPE, STATED SO NOBODY OVER-TRUSTS IT. This is a SYNTACTIC guard on the URL as written. It does NOT
23// resolve DNS, so a public hostname whose A record points at 10.x (DNS rebinding, and the classic
24// time-of-check/time-of-use hole) is ALLOWED here and must be caught at connect time by the fetcher. It
25// blocks all of 172.x rather than only 172.16-31 -- deliberately conservative, and wrong in the direction
26// of refusing a legitimate public host rather than admitting a private one.
27//
28// module: nishi-core.net.url_safety
29// license_tier: ORIGINAL
30
31import "nx_syscalls.nx"
32
33const US_OK: i64 = 0
34const US_BAD_SCHEME: i64 = 1 // neither http:// nor https://
35const US_NO_HOST: i64 = 2 // empty host after the scheme
36const US_LOOPBACK: i64 = 3 // localhost or 127.x
37const US_PRIVATE_10: i64 = 4
38const US_PRIVATE_192: i64 = 5
39const US_LINK_LOCAL: i64 = 6 // 169.254.x -- cloud metadata lives here
40const US_PRIVATE_172: i64 = 7
41const US_ZERO_NET: i64 = 8 // 0.x -- 0.0.0.0 routes to local on many stacks
42const US_IPV6_LITERAL: i64 = 9 // any [..] literal: loopback/ULA/link-local all hide in here
43const US_INTERNAL_TLD: i64 = 10 // .local / .internal / .lan
44const US_REASON_N: i64 = 11
45
46const US_HOST_CAP: i64 = 512
47const US_CH_SLASH: i64 = 47
48const US_CH_COLON: i64 = 58
49const US_CH_QUERY: i64 = 63
50const US_CH_AT: i64 = 64
51const US_CH_UPPER_A: i64 = 65
52const US_CH_UPPER_Z: i64 = 90
53const US_CH_CASE_DELTA: i64 = 32
54
55func us_reason_name(r: i64) -> *u8 {
56 if r == US_OK { return "OK" as *u8 }
57 if r == US_BAD_SCHEME { return "BAD-SCHEME" as *u8 }
58 if r == US_NO_HOST { return "NO-HOST" as *u8 }
59 if r == US_LOOPBACK { return "LOOPBACK" as *u8 }
60 if r == US_PRIVATE_10 { return "PRIVATE-10" as *u8 }
61 if r == US_PRIVATE_192 { return "PRIVATE-192-168" as *u8 }
62 if r == US_LINK_LOCAL { return "LINK-LOCAL-169-254" as *u8 }
63 if r == US_PRIVATE_172 { return "PRIVATE-172" as *u8 }
64 if r == US_ZERO_NET { return "ZERO-NET" as *u8 }
65 if r == US_IPV6_LITERAL { return "IPV6-LITERAL" as *u8 }
66 if r == US_INTERNAL_TLD { return "INTERNAL-TLD" as *u8 }
67 return "UNKNOWN" as *u8
68}
69
70func us_reason_is_valid(r: i64) -> i64 {
71 if r < 0 { return 0 }
72 if r >= US_REASON_N { return 0 }
73 return 1
74}
75
76func us_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n }
77func us_lc(c: i64) -> i64 {
78 if c >= US_CH_UPPER_A { if c <= US_CH_UPPER_Z { return c + US_CH_CASE_DELTA } }
79 return c
80}
81
82// case-insensitive prefix test, carried over from the two incumbents byte-for-byte in behaviour
83func us_pfx_ci(s: *u8, n: i64, pfx: *u8) -> i64 {
84 let pl: i64 = us_slen(pfx)
85 if n < pl { return 0 }
86 var i: i64 = 0
87 var same: i64 = 1
88 while i < pl { if us_lc(s[i] as i64) != us_lc(pfx[i] as i64) { same = 0 } i = i + 1 }
89 return same
90}
91
92// Extract the HOST from a URL: everything after the scheme up to the first '/', ':' or '?', with the
93// userinfo rule that an '@' RESETS what has been collected. That reset is the load-bearing line and it is
94// why https://example.com@127.0.0.1/ is caught: a reader that stopped at the first host-looking run would
95// see "example.com" and allow a request that actually goes to loopback. Returns host length.
96func us_host_of(url: *u8, ulen: i64, host: *u8, cap: i64) -> i64 {
97 var sl: i64 = 0
98 if us_pfx_ci(url, ulen, "https://" as *u8) == 1 { sl = 8 }
99 else { if us_pfx_ci(url, ulen, "http://" as *u8) == 1 { sl = 7 } else { return 0 - 1 } }
100 var h: i64 = 0
101 var i: i64 = sl
102 var scanning: i64 = 1
103 while scanning == 1 {
104 if i >= ulen { scanning = 0 }
105 else {
106 let c: i64 = url[i] & 0xff
107 if c == US_CH_SLASH { scanning = 0 }
108 else {
109 if c == US_CH_COLON { scanning = 0 }
110 else {
111 if c == US_CH_QUERY { scanning = 0 }
112 else {
113 if c == US_CH_AT { h = 0; i = i + 1 }
114 else {
115 if h < cap - 1 { host[h] = c as u8; h = h + 1 }
116 i = i + 1
117 }
118 }
119 }
120 }
121 }
122 }
123 host[h] = 0 as u8
124 return h
125}
126
127// THE DECISION. Returns US_OK or the reason that refused it.
128func us_reason(url: *u8, ulen: i64) -> i64 {
129 let host: *u8 = sys_mmap(US_HOST_CAP)
130 let h: i64 = us_host_of(url, ulen, host, US_HOST_CAP)
131 if h < 0 { return US_BAD_SCHEME }
132 if h == 0 { return US_NO_HOST }
133 if us_pfx_ci(host, h, "localhost" as *u8) == 1 { return US_LOOPBACK }
134 if us_pfx_ci(host, h, "127." as *u8) == 1 { return US_LOOPBACK }
135 if us_pfx_ci(host, h, "10." as *u8) == 1 { return US_PRIVATE_10 }
136 if us_pfx_ci(host, h, "192.168." as *u8) == 1 { return US_PRIVATE_192 }
137 if us_pfx_ci(host, h, "169.254." as *u8) == 1 { return US_LINK_LOCAL }
138 if us_pfx_ci(host, h, "172." as *u8) == 1 { return US_PRIVATE_172 }
139 if us_pfx_ci(host, h, "0." as *u8) == 1 { return US_ZERO_NET }
140 if us_pfx_ci(host, h, "[" as *u8) == 1 { return US_IPV6_LITERAL }
141 if h > 6 { if us_pfx_ci(((host as i64) + h - 6) as *u8, 6, ".local" as *u8) == 1 { return US_INTERNAL_TLD } }
142 if h > 9 { if us_pfx_ci(((host as i64) + h - 9) as *u8, 9, ".internal" as *u8) == 1 { return US_INTERNAL_TLD } }
143 if h > 4 { if us_pfx_ci(((host as i64) + h - 4) as *u8, 4, ".lan" as *u8) == 1 { return US_INTERNAL_TLD } }
144 return US_OK
145}
146
147// The 0/1 shim the two incumbents expose. DEFINED AS the reason being OK, so the boolean and the reason can
148// never disagree -- which is the whole point of having one ruler instead of two.
149func us_url_safe(url: *u8, ulen: i64) -> i64 {
150 if us_reason(url, ulen) == US_OK { return 1 }
151 return 0
152}