Skip to content

Commit 5ce4275

Browse files
authored
Harden coder attachment fetching and pin the AI SDK packages (#261)
1 parent 57f3042 commit 5ce4275

6 files changed

Lines changed: 929 additions & 18 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
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

Comments
 (0)