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

+2 -38
View File
@@ -1,3 +1,5 @@
import { readResponseWithLimit } from "./skills/read-response-with-limit.js";
/**
* Data returned for each skill found in a GitHub repository.
*/
@@ -24,44 +26,6 @@ export const MAX_TOTAL_BYTES = 32 * 1024 * 1024;
/** 10 MB — upper bound for the recursive git tree JSON response. */
const MAX_TREE_BYTES = 10 * 1024 * 1024;
/**
* Read a fetch Response body with a byte-size ceiling.
* Throws if the body exceeds `maxBytes`.
*/
async function readResponseWithLimit(response: Response, maxBytes: number): Promise<string> {
const contentLength = response.headers.get("Content-Length");
if (contentLength !== null && Number(contentLength) > maxBytes) {
throw new Error(`Response too large: ${contentLength} bytes (max ${maxBytes})`);
}
const reader = response.body?.getReader();
if (!reader) {
// Fallback: body not streamable (e.g. older Node fetch polyfill)
const text = await response.text();
if (text.length > maxBytes) {
throw new Error(`Response body too large: ${text.length} bytes (max ${maxBytes})`);
}
return text;
}
const decoder = new TextDecoder();
let totalBytes = 0;
let result = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`Response body exceeded ${maxBytes} bytes`);
}
result += decoder.decode(value, { stream: true });
}
result += decoder.decode();
return result;
}
/**
* Resolve the latest commit SHA for a given ref (branch, tag, or HEAD).
* Returns the 40-character hex SHA on success, null on error.
+3 -1
View File
@@ -3,7 +3,9 @@ import type { AppDatabase } from "../database";
import type { FileInventoryEntry, SkillImportResult } from "../skill-types.js";
import { parseFrontmatterMarkdown } from "../skill-format.js";
import { deriveCanonicalSkillKey, normalizeSkillSlug } from "../skill-keys.js";
import { MAX_SKILL_BYTES } from "../skill-github.js";
import { materializeSkillFiles } from "./materialize";
import { readResponseWithLimit } from "./read-response-with-limit.js";
export interface UrlImportContext {
db: AppDatabase;
@@ -26,7 +28,7 @@ export async function importFromUrl(
if (!response.ok) {
throw new Error(`Failed to fetch skill from URL: ${response.status} ${url}`);
}
const markdown = await response.text();
const markdown = await readResponseWithLimit(response, MAX_SKILL_BYTES);
const parsed = parseFrontmatterMarkdown(markdown);
const nowTs = new Date().toISOString();
@@ -0,0 +1,49 @@
export async function readResponseWithLimit(response: Response, maxBytes: number): Promise<string> {
if (!Number.isSafeInteger(maxBytes) || maxBytes < 0) {
throw new Error(`Invalid response byte limit: ${maxBytes}`);
}
const contentLengthHeader = response.headers.get("Content-Length");
if (contentLengthHeader !== null) {
const contentLength = Number(contentLengthHeader);
if (!Number.isSafeInteger(contentLength) || contentLength < 0) {
throw new Error(`Invalid Content-Length header: ${contentLengthHeader}`);
}
if (contentLength > maxBytes) {
throw new Error(`Response too large: ${contentLength} bytes (max ${maxBytes})`);
}
}
const reader = response.body?.getReader();
if (!reader) {
const text = await response.text();
const byteLength = new TextEncoder().encode(text).byteLength;
if (byteLength > maxBytes) {
throw new Error(`Response body exceeded ${maxBytes} bytes`);
}
return text;
}
const chunks: Uint8Array[] = [];
let totalBytes = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
totalBytes += value.byteLength;
if (totalBytes > maxBytes) {
await reader.cancel();
throw new Error(`Response body exceeded ${maxBytes} bytes`);
}
chunks.push(value);
}
const body = new Uint8Array(totalBytes);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(body);
}