Skip to content

Commit d6b5a97

Browse files
olaservoclaude
andcommitted
Address review: constrain artifact fetches to the allowlist
Security fixes for the well-known source's fetch path: - B1 (SSRF / allowlist bypass): validate every fetched URL — index, each entry's artifact url, and redirect targets — against the origin allowlist and scheme rules via new assertUrlAllowed(). Previously only the configured index origin was gated, so an allowlisted-but-malicious publisher could point an artifact url at an internal host or off-allowlist CDN. Artifact origins (incl. CDNs) must now be on WELL_KNOWN_ALLOWED_ORIGINS. - B2 (silent off-origin redirects): fetch now uses redirect:"manual" and follows hops itself, re-validating each Location (max 5) so a 3xx can't escape the allowlist after the initial check. - N1: the polling update-check now reads the index body through the same size-capped reader instead of an unbounded arrayBuffer(). - N5: WELL_KNOWN_MAX_*_MB=0 (a 0-byte cap) now falls back to the default instead of failing every fetch. - N2/N3: clarifying comments on the tar header-size trust and the zip symlink-attribute fallback (documented as non-escape). Tests: assertUrlAllowed unit cases (off-origin, scheme, http-gate) plus sync integration tests for cross-origin artifact rejection, off-allowlist redirect rejection, and a followed same-origin redirect. Full suite 225 green. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 02fe8e7 commit d6b5a97

7 files changed

Lines changed: 282 additions & 48 deletions

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ GITHUB_ALLOWED_ORGS=acme skilljack-mcp github.com/acme/skills
3737

3838
# Well-known publisher (allowlisted via WELL_KNOWN_ALLOWED_ORIGINS).
3939
# Each entry's SHA-256 digest is verified against the published index.
40+
# Every fetched URL — the index, each artifact, and any redirect target — must
41+
# match an allowlisted origin, so if a publisher serves artifacts from a
42+
# separate CDN origin, add that origin to the list too (comma-separated).
4043
WELL_KNOWN_ALLOWED_ORIGINS=https://example.com \
4144
skilljack-mcp https://example.com/.well-known/agent-skills/
4245

src/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -608,6 +608,8 @@ async function main() {
608608
cacheDir: wellKnownConfig.cacheDir,
609609
maxArtifactBytes: wellKnownConfig.maxArtifactBytes,
610610
maxUnpackedBytes: wellKnownConfig.maxUnpackedBytes,
611+
allowedOrigins: wellKnownConfig.allowedOrigins,
612+
allowHttp: wellKnownConfig.allowHttp,
611613
};
612614

613615
const wkResults = await syncAllWellKnown(currentWellKnownSpecs, wkSyncOptions);
@@ -776,6 +778,8 @@ async function main() {
776778
cacheDir: freshWellKnownConfig.cacheDir,
777779
maxArtifactBytes: freshWellKnownConfig.maxArtifactBytes,
778780
maxUnpackedBytes: freshWellKnownConfig.maxUnpackedBytes,
781+
allowedOrigins: freshWellKnownConfig.allowedOrigins,
782+
allowHttp: freshWellKnownConfig.allowHttp,
779783
};
780784
const wkResults = await syncAllWellKnown(allowedWellKnownSpecs, wkSyncOptions);
781785
for (const result of wkResults) {
@@ -861,6 +865,8 @@ async function main() {
861865
cacheDir: wellKnownConfig.cacheDir,
862866
maxArtifactBytes: wellKnownConfig.maxArtifactBytes,
863867
maxUnpackedBytes: wellKnownConfig.maxUnpackedBytes,
868+
allowedOrigins: wellKnownConfig.allowedOrigins,
869+
allowHttp: wellKnownConfig.allowHttp,
864870
};
865871

866872
wellKnownPollingManager = createWellKnownPollingManager(

src/well-known-config.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
isWellKnownUrl,
55
parseWellKnownUrl,
66
isOriginAllowed,
7+
assertUrlAllowed,
78
getWellKnownCacheKey,
89
getWellKnownCachePath,
910
getWellKnownIndexUrl,
@@ -152,6 +153,46 @@ describe("isOriginAllowed", () => {
152153
});
153154
});
154155

156+
describe("assertUrlAllowed", () => {
157+
const allowed = { allowedOrigins: ["https://example.com"], allowHttp: false };
158+
159+
it("accepts an https url whose origin is allowlisted", () => {
160+
expect(() =>
161+
assertUrlAllowed("https://example.com/skills/a/SKILL.md", allowed)
162+
).not.toThrow();
163+
});
164+
165+
it("rejects an off-allowlist origin (SSRF / cross-origin artifact)", () => {
166+
expect(() =>
167+
assertUrlAllowed("https://169.254.169.254/latest/meta-data", allowed)
168+
).toThrow(/not in WELL_KNOWN_ALLOWED_ORIGINS/);
169+
expect(() =>
170+
assertUrlAllowed("https://cdn.example.com/a.tgz", allowed)
171+
).toThrow(/not in WELL_KNOWN_ALLOWED_ORIGINS/);
172+
});
173+
174+
it("rejects http when allowHttp is false, even if origin matches", () => {
175+
expect(() =>
176+
assertUrlAllowed("http://example.com/a", allowed)
177+
).toThrow(/disallowed scheme/);
178+
});
179+
180+
it("permits http only when allowHttp is set and origin matches", () => {
181+
const cfg = { allowedOrigins: ["http://example.com"], allowHttp: true };
182+
expect(() => assertUrlAllowed("http://example.com/a", cfg)).not.toThrow();
183+
// still origin-gated
184+
expect(() => assertUrlAllowed("http://evil.com/a", cfg)).toThrow(
185+
/not in WELL_KNOWN_ALLOWED_ORIGINS/
186+
);
187+
});
188+
189+
it("rejects non-http(s) schemes (file:, etc.)", () => {
190+
expect(() => assertUrlAllowed("file:///etc/passwd", allowed)).toThrow(
191+
/disallowed scheme/
192+
);
193+
});
194+
});
195+
155196
describe("getWellKnownCacheKey", () => {
156197
it("produces a unique key per host + base path", () => {
157198
const a = getWellKnownCacheKey({

src/well-known-config.ts

Lines changed: 61 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,51 @@ export function isOriginAllowed(spec: WellKnownSpec, config: WellKnownConfig): b
144144
return config.allowedOrigins.some((allowed) => allowed.toLowerCase() === target);
145145
}
146146

147+
/**
148+
* Validate that an arbitrary URL is safe to fetch: it must use an allowed
149+
* scheme (https, or http only when allowHttp is set) and its origin must be on
150+
* the allowlist. Throws with a descriptive message otherwise, returning the
151+
* parsed URL on success.
152+
*
153+
* This is the trust boundary for every network request the well-known source
154+
* makes — not just the configured index origin, but each skill entry's artifact
155+
* `url` and any redirect target. Without it, an allowlisted-but-malicious
156+
* publisher could point `url` at an internal endpoint (SSRF) or an off-allowlist
157+
* host, bypassing both the origin allowlist and the http scheme gate.
158+
*/
159+
export function assertUrlAllowed(
160+
rawUrl: string,
161+
config: Pick<WellKnownConfig, "allowedOrigins" | "allowHttp">,
162+
label = "URL"
163+
): URL {
164+
let parsed: URL;
165+
try {
166+
parsed = new URL(rawUrl);
167+
} catch {
168+
throw new Error(`${label} is not a valid absolute URL: "${rawUrl}"`);
169+
}
170+
171+
const isHttps = parsed.protocol === "https:";
172+
const isAllowedHttp = config.allowHttp && parsed.protocol === "http:";
173+
if (!isHttps && !isAllowedHttp) {
174+
throw new Error(
175+
`${label} uses disallowed scheme "${parsed.protocol}": ${rawUrl}`
176+
);
177+
}
178+
179+
const origin = `${parsed.protocol}//${parsed.host}`;
180+
const allowed = config.allowedOrigins.some(
181+
(o) => o.toLowerCase() === origin.toLowerCase()
182+
);
183+
if (!allowed) {
184+
throw new Error(
185+
`${label} origin "${origin}" is not in WELL_KNOWN_ALLOWED_ORIGINS: ${rawUrl}`
186+
);
187+
}
188+
189+
return parsed;
190+
}
191+
147192
/**
148193
* Parse a comma-separated list from an environment variable.
149194
*/
@@ -181,12 +226,21 @@ function loadAllowedOriginsFromConfig(): string[] {
181226
}
182227

183228
/**
184-
* Parse a positive integer environment variable, falling back to a default.
229+
* Parse a non-negative integer environment variable, falling back to a default.
230+
*
231+
* `minValue` sets the smallest accepted value: size caps pass `1` so that
232+
* `WELL_KNOWN_MAX_ARTIFACT_MB=0` (a 0-byte cap that would fail every fetch)
233+
* falls back to the default instead of silently disabling the feature. The
234+
* poll interval keeps `minValue = 0`, where `0` meaningfully disables polling.
185235
*/
186-
function parseIntEnv(value: string | undefined, fallback: number): number {
236+
function parseIntEnv(
237+
value: string | undefined,
238+
fallback: number,
239+
minValue = 0
240+
): number {
187241
if (value === undefined) return fallback;
188242
const parsed = parseInt(value, 10);
189-
if (Number.isNaN(parsed) || parsed < 0) return fallback;
243+
if (Number.isNaN(parsed) || parsed < minValue) return fallback;
190244
return parsed;
191245
}
192246

@@ -215,8 +269,10 @@ export function getWellKnownConfig(): WellKnownConfig {
215269
? path.join(cacheBase, "well-known")
216270
: DEFAULT_CACHE_DIR;
217271

218-
const maxArtifactMb = parseIntEnv(process.env.WELL_KNOWN_MAX_ARTIFACT_MB, DEFAULT_MAX_ARTIFACT_MB);
219-
const maxUnpackedMb = parseIntEnv(process.env.WELL_KNOWN_MAX_UNPACKED_MB, DEFAULT_MAX_UNPACKED_MB);
272+
// minValue 1: a 0-byte cap is a footgun (fails every fetch), so fall back to
273+
// the default rather than silently disabling downloads.
274+
const maxArtifactMb = parseIntEnv(process.env.WELL_KNOWN_MAX_ARTIFACT_MB, DEFAULT_MAX_ARTIFACT_MB, 1);
275+
const maxUnpackedMb = parseIntEnv(process.env.WELL_KNOWN_MAX_UNPACKED_MB, DEFAULT_MAX_UNPACKED_MB, 1);
220276

221277
return {
222278
pollIntervalMs: parseIntEnv(process.env.WELL_KNOWN_POLL_INTERVAL_MS, DEFAULT_POLL_INTERVAL_MS),

src/well-known-polling.test.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,13 @@ describe("createWellKnownPollingManager", () => {
7676
server = await startServer();
7777
cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "wkpoll-"));
7878
spec = { origin: server.baseUrl, basePath: "/.well-known/agent-skills" };
79-
options = { cacheDir, maxArtifactBytes: 1024 * 1024, maxUnpackedBytes: 1024 * 1024 };
79+
options = {
80+
cacheDir,
81+
maxArtifactBytes: 1024 * 1024,
82+
maxUnpackedBytes: 1024 * 1024,
83+
allowedOrigins: [server.baseUrl],
84+
allowHttp: true,
85+
};
8086
});
8187

8288
afterEach(async () => {

src/well-known-sync.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ interface RouteResponse {
3939
contentType?: string;
4040
body: Buffer | string;
4141
delay?: number;
42+
/** Extra response headers (e.g. Location for redirects). */
43+
headers?: Record<string, string>;
4244
}
4345

4446
interface FixtureServer {
@@ -59,6 +61,7 @@ async function startFixtureServer(): Promise<FixtureServer> {
5961
res.writeHead(route.status ?? 200, {
6062
"Content-Type": route.contentType ?? "application/octet-stream",
6163
"Content-Length": String(body.byteLength),
64+
...route.headers,
6265
});
6366
if (route.delay) {
6467
setTimeout(() => res.end(body), route.delay);
@@ -235,6 +238,8 @@ describe("syncWellKnown", () => {
235238
cacheDir,
236239
maxArtifactBytes: 1024 * 1024,
237240
maxUnpackedBytes: 1024 * 1024,
241+
allowedOrigins: [server.baseUrl],
242+
allowHttp: true,
238243
};
239244
});
240245

@@ -324,6 +329,62 @@ describe("syncWellKnown", () => {
324329
expect(result.skillsSynced).toEqual([]);
325330
});
326331

332+
it("rejects an artifact url whose origin is not on the allowlist (SSRF guard)", async () => {
333+
const body = makeSkillMd("evil", "x");
334+
// Absolute cross-origin url — a would-be SSRF target. It is allowlist-gated
335+
// and never fetched, even though its digest would match.
336+
const offOrigin = "http://169.254.169.254/latest/meta-data/SKILL.md";
337+
setIndex([
338+
{ name: "evil", type: "skill-md", description: "evil", url: offOrigin, digest: digestOf(body) },
339+
]);
340+
341+
const result = await syncWellKnown(spec, options);
342+
343+
expect(result.error).toBeUndefined();
344+
expect(result.skillsSynced).toEqual([]);
345+
expect(result.skillErrors.evil).toMatch(/not in WELL_KNOWN_ALLOWED_ORIGINS/);
346+
});
347+
348+
it("rejects a redirect that leaves the allowlist", async () => {
349+
const body = makeSkillMd("redir", "x");
350+
const { url, digest } = setSkillMd("redir", body);
351+
// Same-origin artifact url, but the server 302s it off-allowlist.
352+
server.routes.set(url, {
353+
status: 302,
354+
body: "",
355+
headers: { Location: "http://169.254.169.254/evil" },
356+
});
357+
setIndex([
358+
{ name: "redir", type: "skill-md", description: "redir", url, digest },
359+
]);
360+
361+
const result = await syncWellKnown(spec, options);
362+
363+
expect(result.error).toBeUndefined();
364+
expect(result.skillsSynced).toEqual([]);
365+
expect(result.skillErrors.redir).toMatch(/not in WELL_KNOWN_ALLOWED_ORIGINS/);
366+
}, 15000);
367+
368+
it("follows a same-origin redirect that stays on the allowlist", async () => {
369+
const body = makeSkillMd("hop", "x");
370+
const finalPath = "/.well-known/agent-skills/hop/real-SKILL.md";
371+
server.routes.set(finalPath, { contentType: "text/markdown", body });
372+
const startPath = "/.well-known/agent-skills/hop/SKILL.md";
373+
server.routes.set(startPath, {
374+
status: 302,
375+
body: "",
376+
headers: { Location: finalPath },
377+
});
378+
setIndex([
379+
{ name: "hop", type: "skill-md", description: "hop", url: startPath, digest: digestOf(body) },
380+
]);
381+
382+
const result = await syncWellKnown(spec, options);
383+
384+
expect(result.skillErrors).toEqual({});
385+
expect(result.skillsSynced).toEqual(["hop"]);
386+
});
387+
327388
it("warns and skips entries with unknown type, but other entries still load", async () => {
328389
const body = makeSkillMd("good", "ok");
329390
const { url, digest } = setSkillMd("good", body);

0 commit comments

Comments
 (0)