code wiki / (root) / nx_url_safety_lib.nx

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}