Compare commits

...

2 commits

Author SHA1 Message Date
RissRIce
1cfec322ac
fix(web): validate ontology pagination (#138)
Some checks failed
CI / build (push) Has been cancelled
test / test (push) Has been cancelled
2026-08-10 18:59:16 -07:00
RissRIce
edf9a1b063
Enforce OpenContext HTTP byte limit while streaming (#136)
Some checks are pending
CI / build (push) Waiting to run
test / test (push) Waiting to run
2026-08-09 21:57:05 -07:00
8 changed files with 159 additions and 36 deletions

View file

@ -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`),

View file

@ -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({

View file

@ -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) {

View file

@ -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 }) });
}

View file

@ -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);
});
});

View file

@ -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);
}

View file

@ -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" });
});
});

View file

@ -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<string> {
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,6 +71,7 @@ export const httpAdapter: Adapter = {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
let response: Response;
try {
response = await fetch(url, {
@ -50,8 +83,6 @@ export const httpAdapter: Adapter = {
});
} catch (error) {
throw new Error(`Failed to fetch ${uri}: ${(error as Error).message}`);
} finally {
clearTimeout(timer);
}
if (!response.ok) {
@ -63,10 +94,7 @@ export const httpAdapter: Adapter = {
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.`);
}
const content = await readBody(response, uri);
return {
content,
@ -75,5 +103,8 @@ export const httpAdapter: Adapter = {
retrievedAt: new Date().toISOString(),
trust: (ctx.config.trust as AdapterResult["trust"]) ?? "untrusted"
};
} finally {
clearTimeout(timer);
}
}
};