|
| 1 | +import { assertEquals, assertThrows } from "jsr:@std/assert"; |
| 2 | +import { assertSafeAttachmentUrl, expandIPv6 } from "./attachment_url.ts"; |
| 3 | + |
| 4 | +const noEnv = () => undefined; |
| 5 | + |
| 6 | +Deno.test("an ordinary https CDN url passes", () => { |
| 7 | + const url = assertSafeAttachmentUrl("https://cdn.example.com/a.png", noEnv); |
| 8 | + assertEquals(url.hostname, "cdn.example.com"); |
| 9 | +}); |
| 10 | + |
| 11 | +Deno.test("rejects a url that does not parse at all", () => { |
| 12 | + assertThrows(() => assertSafeAttachmentUrl("not a url", noEnv)); |
| 13 | +}); |
| 14 | + |
| 15 | +Deno.test("rejects http", () => { |
| 16 | + assertThrows(() => assertSafeAttachmentUrl("http://cdn.example.com/a.png", noEnv)); |
| 17 | +}); |
| 18 | + |
| 19 | +Deno.test("rejects other schemes", () => { |
| 20 | + assertThrows(() => assertSafeAttachmentUrl("file:///etc/passwd", noEnv)); |
| 21 | + assertThrows(() => assertSafeAttachmentUrl("data:text/plain;base64,aGk=", noEnv)); |
| 22 | + assertThrows(() => assertSafeAttachmentUrl("blob:https://example.com/uuid", noEnv)); |
| 23 | + assertThrows(() => assertSafeAttachmentUrl("gopher://example.com/a", noEnv)); |
| 24 | + assertThrows(() => assertSafeAttachmentUrl("ftp://example.com/a", noEnv)); |
| 25 | +}); |
| 26 | + |
| 27 | +Deno.test("rejects urls carrying credentials", () => { |
| 28 | + assertThrows(() => assertSafeAttachmentUrl("https://user@cdn.example.com/a.png", noEnv)); |
| 29 | + assertThrows(() => assertSafeAttachmentUrl("https://user:pass@cdn.example.com/a.png", noEnv)); |
| 30 | +}); |
| 31 | + |
| 32 | +Deno.test("rejects IPv4 non-public ranges", () => { |
| 33 | + const bad = [ |
| 34 | + "https://127.0.0.1/a.png", |
| 35 | + "https://127.5.5.5/a.png", |
| 36 | + "https://10.0.0.5/a.png", |
| 37 | + "https://172.16.0.5/a.png", |
| 38 | + "https://172.31.255.255/a.png", |
| 39 | + "https://192.168.1.1/a.png", |
| 40 | + "https://169.254.169.254/a.png", |
| 41 | + "https://0.0.0.0/a.png", |
| 42 | + "https://100.64.0.1/a.png", |
| 43 | + "https://100.100.100.100/a.png", |
| 44 | + ]; |
| 45 | + for (const u of bad) { |
| 46 | + assertThrows(() => assertSafeAttachmentUrl(u, noEnv), Error, undefined, u); |
| 47 | + } |
| 48 | +}); |
| 49 | + |
| 50 | +Deno.test("allows public IPv4 literals", () => { |
| 51 | + const url = assertSafeAttachmentUrl("https://8.8.8.8/a.png", noEnv); |
| 52 | + assertEquals(url.hostname, "8.8.8.8"); |
| 53 | +}); |
| 54 | + |
| 55 | +Deno.test("rejects IPv6 non-public forms", () => { |
| 56 | + const bad = [ |
| 57 | + "https://[::1]/a.png", |
| 58 | + "https://[fc00::1]/a.png", |
| 59 | + "https://[fe80::1]/a.png", |
| 60 | + "https://[::ffff:127.0.0.1]/a.png", |
| 61 | + "https://[::ffff:169.254.169.254]/a.png", |
| 62 | + ]; |
| 63 | + for (const u of bad) { |
| 64 | + assertThrows(() => assertSafeAttachmentUrl(u, noEnv), Error, undefined, u); |
| 65 | + } |
| 66 | +}); |
| 67 | + |
| 68 | +Deno.test("rejects localhost and .internal hostnames", () => { |
| 69 | + assertThrows(() => assertSafeAttachmentUrl("https://localhost/a.png", noEnv)); |
| 70 | + assertThrows(() => assertSafeAttachmentUrl("https://foo.localhost/a.png", noEnv)); |
| 71 | + assertThrows(() => assertSafeAttachmentUrl("https://foo.internal/a.png", noEnv)); |
| 72 | + assertThrows(() => assertSafeAttachmentUrl("https://svc.foo.internal/a.png", noEnv)); |
| 73 | +}); |
| 74 | + |
| 75 | +Deno.test("allowlist set: matching host passes, non-matching rejected", () => { |
| 76 | + const env = (k: string) => (k === "DEVX_ATTACHMENT_HOST_ALLOWLIST" ? "cdn.example.com,files.example.org" : undefined); |
| 77 | + const url = assertSafeAttachmentUrl("https://cdn.example.com/a.png", env); |
| 78 | + assertEquals(url.hostname, "cdn.example.com"); |
| 79 | + const sub = assertSafeAttachmentUrl("https://sub.files.example.org/a.png", env); |
| 80 | + assertEquals(sub.hostname, "sub.files.example.org"); |
| 81 | + assertThrows(() => assertSafeAttachmentUrl("https://not-allowed.example.net/a.png", env)); |
| 82 | +}); |
| 83 | + |
| 84 | +Deno.test("allowlist blank: deny rules still apply", () => { |
| 85 | + const env = (k: string) => (k === "DEVX_ATTACHMENT_HOST_ALLOWLIST" ? "" : undefined); |
| 86 | + assertThrows(() => assertSafeAttachmentUrl("https://127.0.0.1/a.png", env)); |
| 87 | + const url = assertSafeAttachmentUrl("https://cdn.example.com/a.png", env); |
| 88 | + assertEquals(url.hostname, "cdn.example.com"); |
| 89 | +}); |
| 90 | + |
| 91 | +// FIX B: WHATWG preserves a trailing dot on a domain (it is stripped only |
| 92 | +// from IPv4 literals), so "localhost." and "metadata.google.internal." |
| 93 | +// must be treated the same as their dotless forms. |
| 94 | +Deno.test("rejects a trailing dot used to dodge the hostname rules", () => { |
| 95 | + assertThrows(() => assertSafeAttachmentUrl("https://localhost./a.png", noEnv)); |
| 96 | + assertThrows(() => assertSafeAttachmentUrl("https://metadata.google.internal./a.png", noEnv)); |
| 97 | +}); |
| 98 | + |
| 99 | +Deno.test("trailing dot on a public host is consistent with the allowlist", () => { |
| 100 | + const env = (k: string) => (k === "DEVX_ATTACHMENT_HOST_ALLOWLIST" ? "cdn.example.com" : undefined); |
| 101 | + // The allowlist compares against the trailing-dot-stripped hostname, so an |
| 102 | + // allowlisted host must behave identically whether or not the url spells |
| 103 | + // it with a trailing dot — neither smuggled past nor unfairly excluded. |
| 104 | + const dotless = assertSafeAttachmentUrl("https://cdn.example.com/a.png", env); |
| 105 | + const dotted = assertSafeAttachmentUrl("https://cdn.example.com./a.png", env); |
| 106 | + assertEquals(dotted.hostname.replace(/\.$/, ""), dotless.hostname); |
| 107 | +}); |
| 108 | + |
| 109 | +// FIX C: "::" (all-zero) is the v6 twin of 0.0.0.0 and reaches localhost on |
| 110 | +// Linux; it must be rejected the same as "::1". |
| 111 | +Deno.test("rejects the all-zero IPv6 address", () => { |
| 112 | + assertThrows(() => assertSafeAttachmentUrl("https://[::]/a.png", noEnv)); |
| 113 | +}); |
| 114 | + |
| 115 | +// FIX D: additional non-public ranges. |
| 116 | +Deno.test("rejects IPv6 embedded-IPv4 forms (IPv4-compatible, IPv4-translated, NAT64)", () => { |
| 117 | + const bad = [ |
| 118 | + "https://[::127.0.0.1]/a.png", // IPv4-compatible ::/96, normalises to ::7f00:1 |
| 119 | + "https://[::ffff:0:127.0.0.1]/a.png", // IPv4-translated ::ffff:0:0/96 |
| 120 | + "https://[64:ff9b::127.0.0.1]/a.png", // NAT64 well-known prefix 64:ff9b::/96 |
| 121 | + ]; |
| 122 | + for (const u of bad) { |
| 123 | + assertThrows(() => assertSafeAttachmentUrl(u, noEnv), Error, undefined, u); |
| 124 | + } |
| 125 | +}); |
| 126 | + |
| 127 | +Deno.test("rejects additional IPv4 non-public ranges", () => { |
| 128 | + const bad = [ |
| 129 | + "https://192.0.0.1/a.png", // 192.0.0.0/24 |
| 130 | + "https://198.18.0.1/a.png", // 198.18.0.0/15 benchmarking |
| 131 | + "https://198.19.255.255/a.png", |
| 132 | + "https://224.0.0.1/a.png", // 224.0.0.0/4 multicast |
| 133 | + "https://240.0.0.1/a.png", // 240.0.0.0/4 reserved |
| 134 | + "https://255.255.255.255/a.png", |
| 135 | + ]; |
| 136 | + for (const u of bad) { |
| 137 | + assertThrows(() => assertSafeAttachmentUrl(u, noEnv), Error, undefined, u); |
| 138 | + } |
| 139 | +}); |
| 140 | + |
| 141 | +// FIX E: the IPv6 expander must fail CLOSED — an unparseable bracketed |
| 142 | +// literal must be rejected, not treated as a DNS name (which would let it |
| 143 | +// fall through the deny rules entirely). |
| 144 | +Deno.test("rejects a malformed bracketed IPv6 literal instead of treating it as a hostname", () => { |
| 145 | + assertThrows(() => assertSafeAttachmentUrl("https://[1:::2]/a.png", noEnv)); |
| 146 | +}); |
| 147 | + |
| 148 | +// The url-level test above is already caught by `new URL()` itself refusing |
| 149 | +// to parse "[1:::2]" at all, so it does not, by itself, prove the expander |
| 150 | +// fix. Exercise expandIPv6 directly: before the fix, `filter(Boolean)` |
| 151 | +// silently dropped the empty segment produced by the extra colon and |
| 152 | +// `octets.map(Number)` accepted non-decimal/empty octets, so both inputs |
| 153 | +// below parsed "successfully" (and wrongly) instead of failing closed. |
| 154 | +Deno.test("expandIPv6 fails closed on malformed input rather than silently repairing it", () => { |
| 155 | + assertEquals(expandIPv6("1:::2"), null); // extra colon — filter(Boolean) used to hide it |
| 156 | + assertEquals(expandIPv6("::ffff:0x7f.0.0.1"), null); // hex octet — Number("0x7f") = 127 |
| 157 | + assertEquals(expandIPv6("::ffff:127.0..1"), null); // empty octet — Number("") = 0 |
| 158 | +}); |
| 159 | + |
| 160 | +// FIX 1: public DNS aliases for the cloud metadata endpoint. The link-local |
| 161 | +// IP deny rule (169.254.169.254) does nothing against a public hostname that |
| 162 | +// resolves to it — these must be denied by name. |
| 163 | +Deno.test("rejects known public aliases for the cloud metadata endpoint", () => { |
| 164 | + const bad = [ |
| 165 | + "https://metadata.goog/", |
| 166 | + "https://METADATA.GOOG/", // case-insensitive |
| 167 | + "https://metadata.google.internal/", |
| 168 | + "https://metadata/", |
| 169 | + "https://instance-data/", |
| 170 | + "https://metadata.azure.com/", |
| 171 | + "https://sub.metadata.goog/", // covered by the ".metadata.goog" suffix rule |
| 172 | + "https://metadata.goog./", // trailing dot stripped before the deny check |
| 173 | + ]; |
| 174 | + for (const u of bad) { |
| 175 | + assertThrows(() => assertSafeAttachmentUrl(u, noEnv), Error, undefined, u); |
| 176 | + } |
| 177 | +}); |
| 178 | + |
| 179 | +// A different, attacker-registrable domain that merely CONTAINS the deny |
| 180 | +// string must not be caught by a naive suffix match ("metadata.goog" is not |
| 181 | +// a suffix-anchored parent of "metadata.goog.example.com" — the label |
| 182 | +// boundary is wrong, so this is genuinely a different registrable domain). |
| 183 | +// It is allowed through here; it is still subject to every other deny rule |
| 184 | +// (IP literals, .internal, localhost) and, in a locked-down deployment, to |
| 185 | +// the allowlist. |
| 186 | +Deno.test("does not reject an unrelated host that merely contains a metadata alias as a substring", () => { |
| 187 | + const url = assertSafeAttachmentUrl("https://metadata.goog.example.com/a.png", noEnv); |
| 188 | + assertEquals(url.hostname, "metadata.goog.example.com"); |
| 189 | + // Also prove the naive substring/suffix trap the other way: a host that |
| 190 | + // merely ends with the letters "metadata.goog" without a label boundary |
| 191 | + // must not be denied either. |
| 192 | + const url2 = assertSafeAttachmentUrl("https://notmetadata.goog/a.png", noEnv); |
| 193 | + assertEquals(url2.hostname, "notmetadata.goog"); |
| 194 | +}); |
| 195 | + |
| 196 | +// FIX 2: remaining non-public IPv6/IPv4 ranges. |
| 197 | +Deno.test("rejects 6to4 addresses whose embedded IPv4 is non-public", () => { |
| 198 | + // 2002:a9fe:a9fe:: encodes 169.254.169.254 in groups[1]/groups[2]. |
| 199 | + assertThrows(() => assertSafeAttachmentUrl("https://[2002:a9fe:a9fe::]/", noEnv)); |
| 200 | +}); |
| 201 | + |
| 202 | +Deno.test("allows 6to4 addresses whose embedded IPv4 is public", () => { |
| 203 | + // 2002:0808:0808:: encodes 8.8.8.8. |
| 204 | + const url = assertSafeAttachmentUrl("https://[2002:808:808::]/a.png", noEnv); |
| 205 | + assertEquals(url.hostname, "[2002:808:808::]"); |
| 206 | +}); |
| 207 | + |
| 208 | +Deno.test("rejects deprecated IPv6 site-local addresses", () => { |
| 209 | + assertThrows(() => assertSafeAttachmentUrl("https://[fec0::1]/", noEnv)); |
| 210 | +}); |
| 211 | + |
| 212 | +Deno.test("rejects the 6to4 relay anycast IPv4 range", () => { |
| 213 | + assertThrows(() => assertSafeAttachmentUrl("https://192.88.99.1/a.png", noEnv)); |
| 214 | + assertThrows(() => assertSafeAttachmentUrl("https://192.88.99.255/a.png", noEnv)); |
| 215 | +}); |
| 216 | + |
| 217 | +// FIX 3: allowlist entries must be lowercased (and trimmed) when parsed — |
| 218 | +// otherwise an operator who writes mixed case silently blocks everything. |
| 219 | +Deno.test("allowlist: a mixed-case entry matches a lowercase hostname", () => { |
| 220 | + const env = (k: string) => (k === "DEVX_ATTACHMENT_HOST_ALLOWLIST" ? "CDN.Example.com" : undefined); |
| 221 | + const url = assertSafeAttachmentUrl("https://cdn.example.com/a.png", env); |
| 222 | + assertEquals(url.hostname, "cdn.example.com"); |
| 223 | +}); |
| 224 | + |
| 225 | +// FIX 6c: the existing "trailing dot is consistent with the allowlist" test |
| 226 | +// only proves dotted == dotless for an ALLOWED host. Prove the reject side |
| 227 | +// too: a non-allowlisted host with a trailing dot must still be rejected, |
| 228 | +// not smuggled past the allowlist check by the dot. |
| 229 | +Deno.test("trailing dot does not smuggle a non-allowlisted host past the allowlist", () => { |
| 230 | + const env = (k: string) => (k === "DEVX_ATTACHMENT_HOST_ALLOWLIST" ? "cdn.example.com" : undefined); |
| 231 | + assertThrows(() => assertSafeAttachmentUrl("https://not-allowed.example.net./a.png", env)); |
| 232 | +}); |
0 commit comments