From edf9a1b063342ce6c7b6d2a88e017e43d5e852ec Mon Sep 17 00:00:00 2001 From: RissRIce Date: Sun, 9 Aug 2026 22:57:05 -0600 Subject: [PATCH 1/2] Enforce OpenContext HTTP byte limit while streaming (#136) --- .../opencontext/src/adapters/http.test.ts | 33 +++++++ packages/opencontext/src/adapters/http.ts | 95 ++++++++++++------- 2 files changed, 96 insertions(+), 32 deletions(-) create mode 100644 packages/opencontext/src/adapters/http.test.ts diff --git a/packages/opencontext/src/adapters/http.test.ts b/packages/opencontext/src/adapters/http.test.ts new file mode 100644 index 0000000..8995e75 --- /dev/null +++ b/packages/opencontext/src/adapters/http.test.ts @@ -0,0 +1,33 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { httpAdapter } from "./http.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("http adapter response limits", () => { + it("enforces the byte limit for multibyte responses without a content-length header", async () => { + const content = "é".repeat(3 * 1024 * 1024); + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response(content))); + + await expect( + httpAdapter.load("https://example.com/context.md", { + dir: process.cwd(), + offline: false, + config: {} + }) + ).rejects.toThrow(/response exceeds the 5242880 byte limit/); + }); + + it("decodes responses within the byte limit", async () => { + vi.stubGlobal("fetch", vi.fn().mockResolvedValue(new Response("café"))); + + await expect( + httpAdapter.load("https://example.com/context.md", { + dir: process.cwd(), + offline: false, + config: {} + }) + ).resolves.toMatchObject({ content: "café", trust: "untrusted" }); + }); +}); diff --git a/packages/opencontext/src/adapters/http.ts b/packages/opencontext/src/adapters/http.ts index e900547..a8e7114 100644 --- a/packages/opencontext/src/adapters/http.ts +++ b/packages/opencontext/src/adapters/http.ts @@ -20,6 +20,38 @@ export class OfflineError extends Error { const DEFAULT_TIMEOUT_MS = 10_000; const MAX_BYTES = 5 * 1024 * 1024; +async function readBody(response: Response, uri: string): Promise { + if (!response.body) return ""; + + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + totalBytes += value.byteLength; + if (totalBytes > MAX_BYTES) { + await reader.cancel().catch(() => undefined); + throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`); + } + chunks.push(value); + } + } finally { + reader.releaseLock(); + } + + const bytes = new Uint8Array(totalBytes); + let offset = 0; + for (const chunk of chunks) { + bytes.set(chunk, offset); + offset += chunk.byteLength; + } + return new TextDecoder().decode(bytes); +} + export const httpAdapter: Adapter = { name: "http", schemes: ["http", "https"], @@ -39,41 +71,40 @@ export const httpAdapter: Adapter = { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); - let response: Response; try { - response = await fetch(url, { - signal: controller.signal, - // Redirects can move a request to a host the author never named, so the - // final URL is reported back rather than followed silently. - redirect: "follow", - headers: { accept: "text/markdown, text/plain, application/json;q=0.9, */*;q=0.8" } - }); - } catch (error) { - throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`); + let response: Response; + try { + response = await fetch(url, { + signal: controller.signal, + // Redirects can move a request to a host the author never named, so the + // final URL is reported back rather than followed silently. + redirect: "follow", + headers: { accept: "text/markdown, text/plain, application/json;q=0.9, */*;q=0.8" } + }); + } catch (error) { + throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`); + } + + if (!response.ok) { + throw new Error(`Failed to fetch ${uri}: HTTP ${response.status} ${response.statusText}`); + } + + const declaredLength = Number(response.headers.get("content-length") ?? "0"); + if (declaredLength > MAX_BYTES) { + throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`); + } + + const content = await readBody(response, uri); + + return { + content, + contentType: (response.headers.get("content-type") ?? "text/plain").split(";")[0]!.trim(), + digest: sha256Uri(content), + retrievedAt: new Date().toISOString(), + trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted" + }; } finally { clearTimeout(timer); } - - if (!response.ok) { - throw new Error(`Failed to fetch ${uri}: HTTP ${response.status} ${response.statusText}`); - } - - const declaredLength = Number(response.headers.get("content-length") ?? "0"); - if (declaredLength > MAX_BYTES) { - throw new Error(`Refusing to load ${uri}: ${declaredLength} bytes exceeds the ${MAX_BYTES} byte limit.`); - } - - const content = await response.text(); - if (content.length > MAX_BYTES) { - throw new Error(`Refusing to load ${uri}: response exceeds the ${MAX_BYTES} byte limit.`); - } - - return { - content, - contentType: (response.headers.get("content-type") ?? "text/plain").split(";")[0]!.trim(), - digest: sha256Uri(content), - retrievedAt: new Date().toISOString(), - trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted" - }; } }; From 1cfec322acad3653553ccee01b009ff1276f8589 Mon Sep 17 00:00:00 2001 From: RissRIce Date: Mon, 10 Aug 2026 19:59:16 -0600 Subject: [PATCH 2/2] fix(web): validate ontology pagination (#138) --- .../contract/ontology-api.contract.test.ts | 26 +++++++++++++++++++ .../ontologies/[ontologyId]/claims/route.ts | 3 ++- .../ontologies/[ontologyId]/entities/route.ts | 5 ++-- .../ontologies/[ontologyId]/events/route.ts | 3 ++- apps/logicsrc-web/src/lib/pagination.test.ts | 17 ++++++++++++ apps/logicsrc-web/src/lib/pagination.ts | 13 ++++++++++ 6 files changed, 63 insertions(+), 4 deletions(-) create mode 100644 apps/logicsrc-web/src/lib/pagination.test.ts create mode 100644 apps/logicsrc-web/src/lib/pagination.ts diff --git a/apps/logicsrc-web/contract/ontology-api.contract.test.ts b/apps/logicsrc-web/contract/ontology-api.contract.test.ts index 53ffeac..b4cf725 100644 --- a/apps/logicsrc-web/contract/ontology-api.contract.test.ts +++ b/apps/logicsrc-web/contract/ontology-api.contract.test.ts @@ -102,6 +102,32 @@ describe("reads", () => { expect(payload.total).toBeGreaterThan(3); }); + it("falls back safely for negative and malformed pagination values", async () => { + const entityResponse = await listEntities( + request(`/api/ontologies/${ONTOLOGY}/entities?limit=-1&offset=invalid`), + params({ ontologyId: ONTOLOGY }) + ); + const entityPayload = await body<{ limit: number; offset: number }>(entityResponse); + expect(entityPayload.limit).toBe(50); + expect(entityPayload.offset).toBe(0); + + const claimResponse = await listClaims( + request(`/api/ontologies/${ONTOLOGY}/claims?limit=1.5`), + params({ ontologyId: ONTOLOGY }) + ); + expect((await body<{ limit: number }>(claimResponse)).limit).toBe(100); + + const defaultEvents = await events( + request(`/api/ontologies/${ONTOLOGY}/events`), + params({ ontologyId: ONTOLOGY }) + ); + const invalidEvents = await events( + request(`/api/ontologies/${ONTOLOGY}/events?limit=-1`), + params({ ontologyId: ONTOLOGY }) + ); + expect(await body(invalidEvents)).toEqual(await body(defaultEvents)); + }); + it("returns ranked matches with evidence when searching", async () => { const response = await listEntities( request(`/api/ontologies/${ONTOLOGY}/entities?q=Avery`), diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts index f43ff23..e98d479 100644 --- a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/claims/route.ts @@ -1,4 +1,5 @@ import { handle, apiJson } from "@/lib/ontology-service"; +import { parseBoundedIntegerParam } from "@/lib/pagination"; export const dynamic = "force-dynamic"; @@ -6,7 +7,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto const { ontologyId } = await params; const url = new URL(request.url); const statusParam = url.searchParams.get("status") ?? "asserted"; - const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500); + const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500); return handle(request, ontologyId, (engine) => { const claims = engine.store.listClaims({ diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts index 6156a65..8511661 100644 --- a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/entities/route.ts @@ -1,4 +1,5 @@ import { handle, apiJson } from "@/lib/ontology-service"; +import { parseBoundedIntegerParam } from "@/lib/pagination"; export const dynamic = "force-dynamic"; @@ -8,8 +9,8 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto const url = new URL(request.url); const type = url.searchParams.get("type") ?? undefined; const q = url.searchParams.get("q") ?? undefined; - const limit = Math.min(Number(url.searchParams.get("limit") ?? 50), 200); - const offset = Math.max(Number(url.searchParams.get("offset") ?? 0), 0); + const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 50, 1, 200); + const offset = parseBoundedIntegerParam(url.searchParams.get("offset"), 0, 0, Number.MAX_SAFE_INTEGER); return handle(request, ontologyId, (engine) => { if (q) { diff --git a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts index c56a5c9..3b7d45a 100644 --- a/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts +++ b/apps/logicsrc-web/src/app/api/ontologies/[ontologyId]/events/route.ts @@ -1,4 +1,5 @@ import { getService, apiError, apiJson, engineFor } from "@/lib/ontology-service"; +import { parseBoundedIntegerParam } from "@/lib/pagination"; export const dynamic = "force-dynamic"; @@ -27,7 +28,7 @@ export async function GET(request: Request, { params }: { params: Promise<{ onto const engine = bound.engine; if (!wantsStream) { - const limit = Math.min(Number(url.searchParams.get("limit") ?? 100), 500); + const limit = parseBoundedIntegerParam(url.searchParams.get("limit"), 100, 1, 500); return apiJson({ events: engine.listEvents({ limit }) }); } diff --git a/apps/logicsrc-web/src/lib/pagination.test.ts b/apps/logicsrc-web/src/lib/pagination.test.ts new file mode 100644 index 0000000..77ce56d --- /dev/null +++ b/apps/logicsrc-web/src/lib/pagination.test.ts @@ -0,0 +1,17 @@ +import { describe, test } from "node:test"; +import assert from "node:assert/strict"; +import { parseBoundedIntegerParam } from "./pagination.ts"; + +describe("pagination parameters", () => { + test("uses the fallback for negative, fractional, and malformed values", () => { + for (const value of [null, "-1", "1.5", "invalid", "9007199254740992"]) { + assert.equal(parseBoundedIntegerParam(value, 50, 1, 200), 50); + } + }); + + test("accepts valid values and caps the upper bound", () => { + assert.equal(parseBoundedIntegerParam(" 1 ", 50, 1, 200), 1); + assert.equal(parseBoundedIntegerParam("500", 50, 1, 200), 200); + assert.equal(parseBoundedIntegerParam("0", 0, 0, Number.MAX_SAFE_INTEGER), 0); + }); +}); diff --git a/apps/logicsrc-web/src/lib/pagination.ts b/apps/logicsrc-web/src/lib/pagination.ts new file mode 100644 index 0000000..931cb86 --- /dev/null +++ b/apps/logicsrc-web/src/lib/pagination.ts @@ -0,0 +1,13 @@ +export function parseBoundedIntegerParam( + value: string | null, + fallback: number, + minimum: number, + maximum: number +): number { + const text = value?.trim() ?? ""; + if (!/^\d+$/.test(text)) return fallback; + + const parsed = Number(text); + if (!Number.isSafeInteger(parsed) || parsed < minimum) return fallback; + return Math.min(parsed, maximum); +}