fix(security): limit skill URL import reads

This commit is contained in:
ZacharyZhang-NY committed 2026-05-15 01:18:44 -04:00
1 parent 0cbaabf937
commit f5b3de438e
5 files changed
+238 -39

No files matched your search

+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import { readResponseWithLimit } from "@main/skills/read-response-with-limit";
const encoder = new TextEncoder();
function responseFromChunks(chunks: string[], onCancel?: () => void): Response {
let index = 0;
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
const chunk = chunks[index];
index += 1;
if (chunk === undefined) {
controller.close();
return;
}
controller.enqueue(encoder.encode(chunk));
},
cancel() {
onCancel?.();
},
});
return new Response(stream);
}
describe("readResponseWithLimit", () => {
it("returns text when the UTF-8 byte count matches the limit", async () => {
const input = "hello 世界";
const maxBytes = encoder.encode(input).byteLength;
await expect(readResponseWithLimit(new Response(input), maxBytes)).resolves.toBe(input);
});
it("rejects oversized Content-Length before body reads", async () => {
const response = new Response("small", {
headers: { "Content-Length": "6" },
});
await expect(readResponseWithLimit(response, 5)).rejects.toThrow(
"Response too large: 6 bytes (max 5)",
);
});
it("rejects malformed Content-Length headers", async () => {
const response = new Response("small", {
headers: { "Content-Length": "NaN" },
});
await expect(readResponseWithLimit(response, 5)).rejects.toThrow(
"Invalid Content-Length header: NaN",
);
});
it("cancels the stream when the body exceeds the byte limit while reading", async () => {
let cancelled = false;
const response = responseFromChunks(["abc", "def"], () => {
cancelled = true;
});
await expect(readResponseWithLimit(response, 5)).rejects.toThrow(
"Response body exceeded 5 bytes",
);
expect(cancelled).toBe(true);
});
it("keeps byte-limit checks for non-streamable Response objects", async () => {
const response = {
headers: new Headers(),
body: null,
text: async () => "世界",
} as Response;
await expect(readResponseWithLimit(response, 5)).rejects.toThrow(
"Response body exceeded 5 bytes",
);
});
});
+108
View File
@@ -0,0 +1,108 @@
import { existsSync } from "node:fs";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { AppDatabase } from "@main/database";
import { MAX_SKILL_BYTES } from "@main/skill-github";
import { importFromUrl } from "@main/skills/import-url";
const encoder = new TextEncoder();
function responseFromChunks(chunks: string[], onCancel: () => void): Response {
let index = 0;
return {
ok: true,
status: 200,
headers: new Headers(),
body: {
getReader() {
return {
async read() {
const chunk = chunks[index];
index += 1;
if (chunk === undefined) {
return { done: true, value: undefined };
}
return { done: false, value: encoder.encode(chunk) };
},
async cancel() {
onCancel();
},
};
},
},
async text() {
throw new Error("streaming URL imports should not call response.text()");
},
} as unknown as Response;
}
describe("skill URL import", () => {
let tempDir: string;
let db: AppDatabase;
let companyId: string;
beforeEach(async () => {
tempDir = await mkdtemp(join(tmpdir(), "agentcompany-skill-url-import-"));
db = new AppDatabase(tempDir);
db.init();
companyId = db.core.company.saveCompany({
name: "Skill Import Test",
description: "URL import limit coverage",
status: "active",
});
});
afterEach(async () => {
db.close();
await rm(tempDir, { recursive: true, force: true });
});
it("rejects URL imports before materializing oversized responses", async () => {
const safeFetch = vi.fn(async () => new Response("---\nname: Large\n---\n", {
headers: { "Content-Length": String(MAX_SKILL_BYTES + 1) },
}));
await expect(importFromUrl({
db,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
skillFilesDir: (ownerCompanyId, slug) => join(tempDir, "skills", ownerCompanyId, slug),
}, companyId, "https://example.com/skills/large.md", safeFetch)).rejects.toThrow(
`Response too large: ${MAX_SKILL_BYTES + 1} bytes (max ${MAX_SKILL_BYTES})`,
);
expect(safeFetch).toHaveBeenCalledWith("https://example.com/skills/large.md");
expect(db.core.skills.listCompanySkills(companyId)).toEqual([]);
});
it("cancels chunked URL import reads that exceed the skill byte limit", async () => {
let cancelled = false;
const skillDir = join(tempDir, "skills", companyId, "large");
const header = "---\nname: Large\nslug: large\n---\n";
const firstChunk = "a".repeat(MAX_SKILL_BYTES - encoder.encode(header).byteLength);
const safeFetch = vi.fn(async () => responseFromChunks([header, firstChunk, "b"], () => {
cancelled = true;
}));
await expect(importFromUrl({
db,
logger: {
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
},
skillFilesDir: (ownerCompanyId, slug) => join(tempDir, "skills", ownerCompanyId, slug),
}, companyId, "https://example.com/skills/large.md", safeFetch)).rejects.toThrow(
`Response body exceeded ${MAX_SKILL_BYTES} bytes`,
);
expect(cancelled).toBe(true);
expect(existsSync(skillDir)).toBe(false);
expect(db.core.skills.listCompanySkills(companyId)).toEqual([]);
});
});