From 2f55e1540c31325ef52696a5c28fd190f4c66abd Mon Sep 17 00:00:00 2001 From: Santiago Rivera <87353936+1816x@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:08:22 -0600 Subject: [PATCH 1/3] Protect uploads and bound public inputs --- app/app/api/documents/route.ts | 18 +++- app/app/api/query/route.ts | 20 ++++- app/app/lib/limits.ts | 43 +++++++++ service/rag_service/app.py | 159 ++++++++++++++++++++++++++++----- 4 files changed, 213 insertions(+), 27 deletions(-) create mode 100644 app/app/lib/limits.ts diff --git a/app/app/api/documents/route.ts b/app/app/api/documents/route.ts index fefefdd..1099171 100644 --- a/app/app/api/documents/route.ts +++ b/app/app/api/documents/route.ts @@ -1,5 +1,6 @@ import { NextResponse } from "next/server"; import { addDocument, listDocuments } from "@/app/lib/rag"; +import { INPUT_LIMITS, InputRequestError, readLimitedJson } from "@/app/lib/limits"; export async function GET() { try { @@ -14,19 +15,32 @@ export async function GET() { export async function POST(req: Request) { try { - const body = (await req.json()) as { title?: unknown; text?: unknown }; + const body = await readLimitedJson<{ title?: unknown; text?: unknown }>(req); if (typeof body.title !== "string" || !body.title.trim()) { return NextResponse.json({ error: "title is required" }, { status: 400 }); } + if (body.title.trim().length > INPUT_LIMITS.titleChars) { + return NextResponse.json( + { error: `title must be at most ${INPUT_LIMITS.titleChars} characters` }, + { status: 422 }, + ); + } if (typeof body.text !== "string" || !body.text.trim()) { return NextResponse.json({ error: "document text is required" }, { status: 400 }); } + if (body.text.length > INPUT_LIMITS.documentChars) { + return NextResponse.json( + { error: `document text must be at most ${INPUT_LIMITS.documentChars} characters` }, + { status: 422 }, + ); + } return NextResponse.json(await addDocument(body.title.trim(), body.text)); } catch (err) { + const status = err instanceof InputRequestError ? err.status : 502; return NextResponse.json( { error: err instanceof Error ? err.message : "unknown error" }, - { status: 502 }, + { status }, ); } } diff --git a/app/app/api/query/route.ts b/app/app/api/query/route.ts index ddb9df9..bd47b23 100644 --- a/app/app/api/query/route.ts +++ b/app/app/api/query/route.ts @@ -1,18 +1,30 @@ import { NextResponse } from "next/server"; import { query } from "@/app/lib/rag"; +import { INPUT_LIMITS, InputRequestError, readLimitedJson } from "@/app/lib/limits"; export async function POST(req: Request) { try { - const { question, k } = await req.json(); - if (!question || typeof question !== "string") { + const body = await readLimitedJson<{ question?: unknown; k?: unknown }>(req); + if (typeof body.question !== "string" || !body.question.trim()) { return NextResponse.json({ error: "question is required" }, { status: 400 }); } - const result = await query(question, k ?? 5); + if (body.question.trim().length > INPUT_LIMITS.questionChars) { + return NextResponse.json( + { error: `question must be at most ${INPUT_LIMITS.questionChars} characters` }, + { status: 422 }, + ); + } + + const result = await query( + body.question.trim(), + typeof body.k === "number" ? body.k : 5, + ); return NextResponse.json(result); } catch (err) { + const status = err instanceof InputRequestError ? err.status : 502; return NextResponse.json( { error: err instanceof Error ? err.message : "unknown error" }, - { status: 502 }, + { status }, ); } } diff --git a/app/app/lib/limits.ts b/app/app/lib/limits.ts new file mode 100644 index 0000000..9bbfe36 --- /dev/null +++ b/app/app/lib/limits.ts @@ -0,0 +1,43 @@ +const DEFAULT_MAX_REQUEST_BYTES = 4 * 1024 * 1024; + +function positiveInt(name: string, fallback: number): number { + const raw = process.env[name]; + if (!raw) return fallback; + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback; +} + +export const INPUT_LIMITS = { + titleChars: positiveInt("RAG_MAX_TITLE_CHARS", 200), + documentChars: positiveInt("RAG_MAX_DOCUMENT_CHARS", 1_000_000), + questionChars: positiveInt("RAG_MAX_QUESTION_CHARS", 2_000), + requestBytes: positiveInt("RAG_MAX_REQUEST_BYTES", DEFAULT_MAX_REQUEST_BYTES), +} as const; + +export class InputRequestError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "InputRequestError"; + } +} + +export async function readLimitedJson(request: Request): Promise { + const declaredLength = Number(request.headers.get("content-length")); + if (Number.isFinite(declaredLength) && declaredLength > INPUT_LIMITS.requestBytes) { + throw new InputRequestError("request body is too large", 413); + } + + const bytes = await request.arrayBuffer(); + if (bytes.byteLength > INPUT_LIMITS.requestBytes) { + throw new InputRequestError("request body is too large", 413); + } + + try { + return JSON.parse(new TextDecoder().decode(bytes)) as T; + } catch { + throw new InputRequestError("request body must be valid JSON", 400); + } +} diff --git a/service/rag_service/app.py b/service/rag_service/app.py index 8f488ce..4c44f43 100644 --- a/service/rag_service/app.py +++ b/service/rag_service/app.py @@ -20,9 +20,11 @@ import pathlib from typing import AsyncIterator, List, Optional -from fastapi import FastAPI +from fastapi import Depends, FastAPI, HTTPException from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel, Field +from fastapi.responses import JSONResponse +from pydantic import BaseModel, Field, field_validator, model_validator +from starlette.types import ASGIApp, Message, Receive, Scope, Send from .generation import generate_answer from .store import DocumentStore @@ -30,6 +32,85 @@ log = logging.getLogger(__name__) + +def _env_flag(name: str, default: str = "0") -> bool: + return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} + + +def _positive_env_int(name: str, default: int) -> int: + raw = os.environ.get(name, str(default)) + try: + value = int(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be an integer") from exc + if value <= 0: + raise RuntimeError(f"{name} must be greater than zero") + return value + + +MAX_TITLE_CHARS = _positive_env_int("RAG_MAX_TITLE_CHARS", 200) +MAX_DOCUMENT_CHARS = _positive_env_int("RAG_MAX_DOCUMENT_CHARS", 1_000_000) +MAX_QUESTION_CHARS = _positive_env_int("RAG_MAX_QUESTION_CHARS", 2_000) +MAX_REQUEST_BYTES = _positive_env_int("RAG_MAX_REQUEST_BYTES", 4 * 1024 * 1024) + + +class BodySizeLimitMiddleware: + """Reject oversized request bodies before JSON parsing.""" + + def __init__(self, app: ASGIApp, max_bytes: int) -> None: + self.app = app + self.max_bytes = max_bytes + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] != "http" or scope.get("method") not in {"POST", "PUT", "PATCH"}: + await self.app(scope, receive, send) + return + + headers = dict(scope.get("headers", [])) + raw_length = headers.get(b"content-length") + if raw_length is not None: + try: + if int(raw_length) > self.max_bytes: + await self._reject(scope, receive, send) + return + except ValueError: + pass + + messages: List[Message] = [] + total = 0 + more_body = True + while more_body: + message = await receive() + messages.append(message) + if message["type"] == "http.disconnect": + break + if message["type"] == "http.request": + total += len(message.get("body", b"")) + if total > self.max_bytes: + await self._reject(scope, receive, send) + return + more_body = message.get("more_body", False) + + position = 0 + + async def replay() -> Message: + nonlocal position + if position < len(messages): + message = messages[position] + position += 1 + return message + return {"type": "http.request", "body": b"", "more_body": False} + + await self.app(scope, replay, send) + + async def _reject(self, scope: Scope, receive: Receive, send: Send) -> None: + response = JSONResponse( + {"detail": f"request body exceeds {self.max_bytes} bytes"}, + status_code=413, + ) + await response(scope, receive, send) + + # One in-memory store for the process. The embedder backend is chosen at # startup: real model if available, deterministic hashed fallback otherwise. _embedder = get_embedder(os.environ.get("RAG_EMBEDDER", "auto")) @@ -41,10 +122,6 @@ SAMPLE_DOCS = pathlib.Path(__file__).resolve().parent.parent / "sample_docs" -def _env_flag(name: str, default: str = "0") -> bool: - return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} - - def seed_sample_docs() -> int: """Index the bundled sample corpus in-process. Returns documents added. @@ -64,13 +141,7 @@ def seed_sample_docs() -> int: @contextlib.asynccontextmanager async def lifespan(_: FastAPI) -> AsyncIterator[None]: - """Optionally seed the sample corpus on boot. - - Off by default so local runs and the test suite keep their existing - behavior (seed explicitly via `scripts/seed.py` or a fixture). Deployments - turn it on — see `RAG_SEED_ON_STARTUP` in fly.toml. Also guarded on an - empty index so it can never double-seed. - """ + """Optionally seed the sample corpus on boot.""" if _env_flag("RAG_SEED_ON_STARTUP") and _store.stats()["chunks"] == 0: added = seed_sample_docs() log.info("seeded %d sample document(s) on startup", added) @@ -79,20 +150,47 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: app = FastAPI(title="RAG Engine service", version="0.1.0", lifespan=lifespan) -# The Next.js dev server calls this cross-origin. +# CORS becomes configurable in the next hardening step. app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["*"], allow_headers=["*"], ) +app.add_middleware(BodySizeLimitMiddleware, max_bytes=MAX_REQUEST_BYTES) + + +def _require_uploads_enabled() -> None: + if not _env_flag("RAG_UPLOADS_ENABLED"): + raise HTTPException(status_code=403, detail="document uploads are disabled") class DocumentIn(BaseModel): - title: str = Field(..., min_length=1) - text: str = Field(..., min_length=1) - max_words: int = 180 - overlap: int = 40 + title: str = Field(..., min_length=1, max_length=MAX_TITLE_CHARS) + text: str = Field(..., min_length=1, max_length=MAX_DOCUMENT_CHARS) + max_words: int = Field(180, ge=1, le=1000) + overlap: int = Field(40, ge=0, le=999) + + @field_validator("title") + @classmethod + def normalize_title(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("title must not be blank") + return value + + @field_validator("text") + @classmethod + def reject_blank_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("text must not be blank") + return value + + @model_validator(mode="after") + def validate_chunk_window(self): + if self.overlap >= self.max_words: + raise ValueError("overlap must be smaller than max_words") + return self class DocumentOut(BaseModel): @@ -102,9 +200,17 @@ class DocumentOut(BaseModel): class QueryIn(BaseModel): - question: str = Field(..., min_length=1) + question: str = Field(..., min_length=1, max_length=MAX_QUESTION_CHARS) k: int = Field(5, ge=1, le=50) - ef_search: int = Field(100, ge=1) + ef_search: int = Field(100, ge=1, le=2000) + + @field_validator("question") + @classmethod + def normalize_question(cls, value: str) -> str: + value = value.strip() + if not value: + raise ValueError("question must not be blank") + return value class SourceOut(BaseModel): @@ -133,6 +239,13 @@ def healthz() -> dict: def stats() -> dict: data = _store.stats() data["generation"] = "claude" if os.environ.get("ANTHROPIC_API_KEY") else "mock" + data["uploads_enabled"] = _env_flag("RAG_UPLOADS_ENABLED") + data["limits"] = { + "title_chars": MAX_TITLE_CHARS, + "document_chars": MAX_DOCUMENT_CHARS, + "question_chars": MAX_QUESTION_CHARS, + "request_bytes": MAX_REQUEST_BYTES, + } return data @@ -144,7 +257,11 @@ def list_documents() -> List[DocumentOut]: ] -@app.post("/documents", response_model=DocumentOut) +@app.post( + "/documents", + response_model=DocumentOut, + dependencies=[Depends(_require_uploads_enabled)], +) def add_document(doc: DocumentIn) -> DocumentOut: result = _store.add_document( doc.title, doc.text, max_words=doc.max_words, overlap=doc.overlap From c784805bd68bca5a73e3759152661515ba785524 Mon Sep 17 00:00:00 2001 From: Santiago Rivera <87353936+1816x@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:10:07 -0600 Subject: [PATCH 2/3] Bound Claude calls and configure CORS --- app/app/api/documents/route.ts | 14 ++----- app/app/api/query/route.ts | 9 ++-- app/app/api/stats/route.ts | 6 +-- app/app/lib/api-errors.ts | 14 +++++++ app/app/lib/rag.ts | 44 ++++++++++++++++--- service/rag_service/app.py | 35 ++++++++++++---- service/rag_service/generation.py | 70 ++++++++++++++++++------------- 7 files changed, 129 insertions(+), 63 deletions(-) create mode 100644 app/app/lib/api-errors.ts diff --git a/app/app/api/documents/route.ts b/app/app/api/documents/route.ts index 1099171..077c6bb 100644 --- a/app/app/api/documents/route.ts +++ b/app/app/api/documents/route.ts @@ -1,15 +1,13 @@ import { NextResponse } from "next/server"; import { addDocument, listDocuments } from "@/app/lib/rag"; -import { INPUT_LIMITS, InputRequestError, readLimitedJson } from "@/app/lib/limits"; +import { INPUT_LIMITS, readLimitedJson } from "@/app/lib/limits"; +import { apiErrorResponse } from "@/app/lib/api-errors"; export async function GET() { try { return NextResponse.json(await listDocuments()); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "unknown error" }, - { status: 502 }, - ); + return apiErrorResponse(err); } } @@ -37,10 +35,6 @@ export async function POST(req: Request) { return NextResponse.json(await addDocument(body.title.trim(), body.text)); } catch (err) { - const status = err instanceof InputRequestError ? err.status : 502; - return NextResponse.json( - { error: err instanceof Error ? err.message : "unknown error" }, - { status }, - ); + return apiErrorResponse(err); } } diff --git a/app/app/api/query/route.ts b/app/app/api/query/route.ts index bd47b23..e93ece8 100644 --- a/app/app/api/query/route.ts +++ b/app/app/api/query/route.ts @@ -1,6 +1,7 @@ import { NextResponse } from "next/server"; import { query } from "@/app/lib/rag"; -import { INPUT_LIMITS, InputRequestError, readLimitedJson } from "@/app/lib/limits"; +import { INPUT_LIMITS, readLimitedJson } from "@/app/lib/limits"; +import { apiErrorResponse } from "@/app/lib/api-errors"; export async function POST(req: Request) { try { @@ -21,10 +22,6 @@ export async function POST(req: Request) { ); return NextResponse.json(result); } catch (err) { - const status = err instanceof InputRequestError ? err.status : 502; - return NextResponse.json( - { error: err instanceof Error ? err.message : "unknown error" }, - { status }, - ); + return apiErrorResponse(err); } } diff --git a/app/app/api/stats/route.ts b/app/app/api/stats/route.ts index 1027f0f..1bcbeeb 100644 --- a/app/app/api/stats/route.ts +++ b/app/app/api/stats/route.ts @@ -1,13 +1,11 @@ import { NextResponse } from "next/server"; import { getStats } from "@/app/lib/rag"; +import { apiErrorResponse } from "@/app/lib/api-errors"; export async function GET() { try { return NextResponse.json(await getStats()); } catch (err) { - return NextResponse.json( - { error: err instanceof Error ? err.message : "unknown error" }, - { status: 502 }, - ); + return apiErrorResponse(err); } } diff --git a/app/app/lib/api-errors.ts b/app/app/lib/api-errors.ts new file mode 100644 index 0000000..f5726c7 --- /dev/null +++ b/app/app/lib/api-errors.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; +import { InputRequestError } from "./limits"; +import { RagServiceError } from "./rag"; + +export function apiErrorResponse(error: unknown) { + const status = + error instanceof InputRequestError || error instanceof RagServiceError + ? error.status + : 502; + return NextResponse.json( + { error: error instanceof Error ? error.message : "unknown error" }, + { status }, + ); +} diff --git a/app/app/lib/rag.ts b/app/app/lib/rag.ts index 85a84d2..5210d59 100644 --- a/app/app/lib/rag.ts +++ b/app/app/lib/rag.ts @@ -40,17 +40,51 @@ export interface Stats { generation: "mock" | "claude"; } +export class RagServiceError extends Error { + constructor( + message: string, + public readonly status: number, + ) { + super(message); + this.name = "RagServiceError"; + } +} + +function errorMessage(body: string, status: number): string { + try { + const parsed = JSON.parse(body) as { detail?: unknown; error?: unknown }; + if (typeof parsed.detail === "string") return parsed.detail; + if (typeof parsed.error === "string") return parsed.error; + if (Array.isArray(parsed.detail)) { + const messages = parsed.detail + .map((item) => + typeof item === "object" && + item !== null && + "msg" in item && + typeof item.msg === "string" + ? item.msg + : null, + ) + .filter((message): message is string => message !== null); + if (messages.length) return messages.join("; "); + } + } catch { + // Preserve a non-JSON service response below. + } + return body || `RAG service request failed with status ${status}`; +} + async function call(path: string, init?: RequestInit): Promise { - const res = await fetch(`${SERVICE_URL}${path}`, { + const response = await fetch(`${SERVICE_URL}${path}`, { ...init, headers: { "Content-Type": "application/json", ...(init?.headers ?? {}) }, cache: "no-store", }); - if (!res.ok) { - const body = await res.text(); - throw new Error(`RAG service ${res.status}: ${body}`); + if (!response.ok) { + const body = await response.text(); + throw new RagServiceError(errorMessage(body, response.status), response.status); } - return res.json() as Promise; + return response.json() as Promise; } export function query(question: string, k = 5): Promise { diff --git a/service/rag_service/app.py b/service/rag_service/app.py index 4c44f43..4a149e4 100644 --- a/service/rag_service/app.py +++ b/service/rag_service/app.py @@ -26,7 +26,7 @@ from pydantic import BaseModel, Field, field_validator, model_validator from starlette.types import ASGIApp, Message, Receive, Scope, Send -from .generation import generate_answer +from .generation import GenerationTimeoutError, generate_answer from .store import DocumentStore from hnsw_rag import get_embedder @@ -37,6 +37,19 @@ def _env_flag(name: str, default: str = "0") -> bool: return os.environ.get(name, default).strip().lower() in {"1", "true", "yes", "on"} +def _cors_origins() -> List[str]: + raw = os.environ.get("RAG_CORS_ORIGINS", "").strip() + if not raw: + return [] + if raw == "*": + return ["*"] + + origins = list(dict.fromkeys(item.strip() for item in raw.split(",") if item.strip())) + if "*" in origins: + raise RuntimeError("RAG_CORS_ORIGINS must be '*' or a list of exact origins") + return origins + + def _positive_env_int(name: str, default: int) -> int: raw = os.environ.get(name, str(default)) try: @@ -150,13 +163,14 @@ async def lifespan(_: FastAPI) -> AsyncIterator[None]: app = FastAPI(title="RAG Engine service", version="0.1.0", lifespan=lifespan) -# CORS becomes configurable in the next hardening step. -app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], -) +cors_origins = _cors_origins() +if cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=cors_origins, + allow_methods=["GET", "POST", "OPTIONS"], + allow_headers=["Content-Type"], + ) app.add_middleware(BodySizeLimitMiddleware, max_bytes=MAX_REQUEST_BYTES) @@ -272,7 +286,10 @@ def add_document(doc: DocumentIn) -> DocumentOut: @app.post("/query", response_model=QueryOut) def query(q: QueryIn) -> QueryOut: chunks = _store.retrieve(q.question, k=q.k, ef_search=q.ef_search) - answer = generate_answer(q.question, chunks) + try: + answer = generate_answer(q.question, chunks) + except GenerationTimeoutError as exc: + raise HTTPException(status_code=504, detail=str(exc)) from exc cited = set(answer.cited_chunk_ids) sources = [ SourceOut( diff --git a/service/rag_service/generation.py b/service/rag_service/generation.py index 9da5d48..08c5878 100644 --- a/service/rag_service/generation.py +++ b/service/rag_service/generation.py @@ -15,9 +15,8 @@ from .store import RetrievedChunk -# Latest Sonnet is a good default for grounded RAG answers: fast, cheap, and -# strong at instruction-following. Override with RAG_MODEL if you want Opus. DEFAULT_MODEL = "claude-sonnet-5" +DEFAULT_CLAUDE_TIMEOUT_SECONDS = 30.0 SYSTEM_PROMPT = """You answer questions using only the provided source chunks. @@ -29,6 +28,14 @@ - Be concise. Lead with the answer, then support it.""" +class GenerationTimeoutError(TimeoutError): + def __init__(self, timeout_seconds: float) -> None: + self.timeout_seconds = timeout_seconds + super().__init__( + f"Claude generation timed out after {timeout_seconds:g} seconds" + ) + + @dataclass class Answer: text: str @@ -36,35 +43,38 @@ class Answer: model: str # "claude-…" or "mock" +def _positive_env_float(name: str, default: float) -> float: + raw = os.environ.get(name, str(default)) + try: + value = float(raw) + except ValueError as exc: + raise RuntimeError(f"{name} must be a number") from exc + if value <= 0: + raise RuntimeError(f"{name} must be greater than zero") + return value + + def _format_sources(chunks: List[RetrievedChunk]) -> str: blocks = [] - for i, c in enumerate(chunks, start=1): - blocks.append(f"[{i}] (source: {c.doc_title})\n{c.text}") + for i, chunk in enumerate(chunks, start=1): + blocks.append(f"[{i}] (source: {chunk.doc_title})\n{chunk.text}") return "\n\n".join(blocks) def parse_citations(text: str, chunks: List[RetrievedChunk]) -> List[int]: - """Map the `[n]` markers in an answer back to the chunk ids they refer to. - - The sources are numbered 1..len(chunks) in the prompt, so `[2]` means - `chunks[1]`. Out-of-range markers (the model inventing `[9]` for three - sources) are ignored rather than trusted. Returns ids in first-mention - order, deduplicated — so a "cited" flag actually means the answer used - that chunk, instead of just "we retrieved it". - """ + """Map answer citation markers back to chunk ids.""" cited: List[int] = [] for marker in re.findall(r"\[(\d+)\]", text): idx = int(marker) - 1 if 0 <= idx < len(chunks): - cid = chunks[idx].id - if cid not in cited: - cited.append(cid) + chunk_id = chunks[idx].id + if chunk_id not in cited: + cited.append(chunk_id) return cited def _mock_answer(question: str, chunks: List[RetrievedChunk]) -> Answer: - """Deterministic, keyless fallback: return the single most relevant chunk - as the answer, cited. Good enough to prove the pipeline end to end.""" + """Return the most relevant chunk as a deterministic, keyless answer.""" if not chunks: return Answer( text="I don't have any indexed documents that address that question.", @@ -85,11 +95,7 @@ def generate_answer( *, model: Optional[str] = None, ) -> Answer: - """Generate an answer to `question` grounded in `chunks`. - - Falls back to mock mode when ANTHROPIC_API_KEY is unset or the `anthropic` - package isn't installed. - """ + """Generate an answer grounded in chunks, or use keyless mock mode.""" api_key = os.environ.get("ANTHROPIC_API_KEY") if not api_key: return _mock_answer(question, chunks) @@ -106,20 +112,26 @@ def generate_answer( model="mock", ) + timeout_seconds = _positive_env_float( + "RAG_CLAUDE_TIMEOUT_SECONDS", DEFAULT_CLAUDE_TIMEOUT_SECONDS + ) model = model or os.environ.get("RAG_MODEL", DEFAULT_MODEL) - client = anthropic.Anthropic() + client = anthropic.Anthropic(timeout=timeout_seconds, max_retries=0) user_content = ( f"Sources:\n\n{_format_sources(chunks)}\n\n" f"Question: {question}\n\n" f"Answer using only the sources above, citing them with [n] markers." ) - response = client.messages.create( - model=model, - max_tokens=1024, - system=SYSTEM_PROMPT, - messages=[{"role": "user", "content": user_content}], - ) + try: + response = client.messages.create( + model=model, + max_tokens=1024, + system=SYSTEM_PROMPT, + messages=[{"role": "user", "content": user_content}], + ) + except anthropic.APITimeoutError as exc: + raise GenerationTimeoutError(timeout_seconds) from exc if response.stop_reason == "refusal": return Answer( From 7bc7c2929ae9c9737170cbabef813b1eaabde295 Mon Sep 17 00:00:00 2001 From: Santiago Rivera <87353936+1816x@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:17:26 -0600 Subject: [PATCH 3/3] Add hardening regressions and surface deployment limits --- README.md | 43 ++++- app/app/globals.css | 62 +++++++ app/app/lib/rag.ts | 7 + app/app/page.tsx | 65 ++++++- fly.toml | 7 + service/rag_service/app.py | 20 +- service/tests/test_e2e.py | 222 ++++++++++++++++++++--- service/tests/test_generation_timeout.py | 46 +++++ 8 files changed, 422 insertions(+), 50 deletions(-) create mode 100644 service/tests/test_generation_timeout.py diff --git a/README.md b/README.md index f6d8cc5..474913f 100644 --- a/README.md +++ b/README.md @@ -47,7 +47,7 @@ The `Dockerfile` is multi-stage — stage one compiles the PyO3 wheel with the R - **No `ANTHROPIC_API_KEY`.** It runs in mock mode: answers are extractive, tagged with a visible `mock` badge. Nothing calls Claude, so there's no key on a public endpoint and no spend to burn. Retrieval — the HNSW index, which is the point of the project — is fully real. - **The hashed fallback embedder**, not `sentence-transformers` (which would drag `torch` into the image). That means retrieval matches on *term overlap, not meaning*. Don't mistake the demo for semantic search; install `sentence-transformers` and set `RAG_EMBEDDER=model` for that. `GET /stats` reports which backend is live so you never have to guess. - **Cold starts.** Scaled to zero, the first request after an idle period waits a second or two for the machine to wake. -- **A live document workspace.** The UI accepts Markdown or plain text, lists every indexed document, and shows the active corpus, embedding, HNSW, relevance, and generation configuration. Documents are held in memory and disappear when the service starts fresh. +- **A visible, locked document workspace.** The UI lists every indexed document and shows the active corpus, embedding, HNSW, relevance, generation, and upload configuration. Public uploads are disabled by default; enabling them is an explicit deployment choice. Documents are held in memory and disappear when the service starts fresh. ## Quick start @@ -82,8 +82,8 @@ idx.search(query, k=10) # -> [(id, distance), ...] closest first ```sh # terminal 1 — the Python service (reuses the bindings venv) cd service && pip install -r requirements.txt -uvicorn rag_service.app:app --port 8000 -python scripts/seed.py # upload the sample corpus +RAG_UPLOADS_ENABLED=1 uvicorn rag_service.app:app --port 8000 +python scripts/seed.py # uploads require the flag above # terminal 2 — the Next.js UI cd app && npm install @@ -105,10 +105,37 @@ The HNSW index remains real in every mode. Its live document count, chunk count, vector dimension, metric, `M`, `ef_construction`, and relevance floor are visible alongside the document library. -To add material from the UI, select a `.md`, `.markdown`, or `.txt` file -(or paste text), give it a title, and choose **Add to index**. The browser reads -the file as text and sends the title and content to the existing document API; -no file is stored on disk. +The upload panel remains visible when uploads are disabled, but its controls +are locked and explain how to opt in. To add material locally, start both Fly.io +and Vercel-compatible environments with `RAG_UPLOADS_ENABLED=1`, then select a +`.md`, `.markdown`, or `.txt` file (or paste text), give it a title, and +choose **Add to index**. The browser reads the file as text; no file is stored +on disk. `scripts/seed.py` uses the same protected upload endpoint and +therefore also requires uploads to be enabled. Startup seeding is unaffected +because it inserts directly into the in-process store. + +### Public API hardening + +| Variable | Default | Behavior | +|----------|---------|----------| +| `RAG_UPLOADS_ENABLED` | `0` | Enables `POST /documents` only when explicitly set to `1`, `true`, `yes`, or `on`. | +| `RAG_MAX_TITLE_CHARS` | `200` | Maximum document title length. | +| `RAG_MAX_DOCUMENT_CHARS` | `1000000` | Maximum document text length. | +| `RAG_MAX_QUESTION_CHARS` | `2000` | Maximum query length. | +| `RAG_MAX_REQUEST_BYTES` | `4194304` | Maximum HTTP request body size (4 MiB), enforced before JSON parsing. | +| `RAG_CLAUDE_TIMEOUT_SECONDS` | `30` | Claude request timeout; automatic SDK retries are disabled. | +| `RAG_CORS_ORIGINS` | empty | Exact comma-separated allowed origins. Empty installs no CORS middleware; `*` restores wildcard access explicitly. | + +Chunking accepts `max_words` from 1 through 1000 and `overlap` from 0 +through 999, with overlap strictly smaller than the chunk size. Query `k` +remains 1 through 50 and `ef_search` is limited to 1 through 2000. Oversized +bodies return `413`, invalid fields return `422`, disabled uploads return +`403`, and Claude timeouts return `504`. The Next.js same-origin proxy +preserves these status codes and service messages. + +If deployment-specific limits are changed, set the same `RAG_MAX_*` values in +both the service environment (Fly.io) and the Next.js environment (Vercel) so +the UI, proxy, and backend enforce one contract. ## Roadmap @@ -160,7 +187,7 @@ This section grows as the project does; each phase documents the trade-offs it m |-------|---------|-------| | Rust engine | `cargo test` | 24 (unit + seeded recall vs. brute force + doctest) | | Python bindings + helpers | `pytest` in `bindings/` | 21 (FFI surface, atomic batches, validation, chunking, embeddings, E2E retrieval) | -| RAG service | `PYTHONPATH=. pytest` in `service/` | 17 (keyless E2E, relevance filtering, citation parsing, startup seeding) | +| RAG service | `PYTHONPATH=. pytest` in `service/` | 36 (keyless E2E, hardening limits, CORS, Claude timeout, relevance filtering, citation parsing, startup seeding) | | Next.js app | `npm run build` | type-checked production build | CI (`.github/workflows/ci.yml`) runs all four on every push, in parallel jobs: `cargo fmt --check` + `cargo clippy -- -D warnings` + `cargo test`, the bindings suite, the service suite, and the app build. diff --git a/app/app/globals.css b/app/app/globals.css index 0359bd2..732c4cd 100644 --- a/app/app/globals.css +++ b/app/app/globals.css @@ -709,3 +709,65 @@ button:disabled { width: 100%; } } + + +.upload-panel.locked { + border-color: rgba(247, 200, 115, 0.3); + background: rgba(23, 24, 29, 0.9); +} + +.upload-status { + border: 1px solid currentColor; + border-radius: 999px; + padding: 4px 9px; + font-size: 0.66rem; + font-weight: 750; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.upload-status.enabled { + color: var(--green); +} + +.upload-status.locked { + color: var(--amber); +} + +.upload-lock-notice { + display: grid; + gap: 4px; + margin-bottom: 16px; + padding: 12px 13px; + border: 1px solid rgba(247, 200, 115, 0.3); + border-radius: 10px; + color: #f4d79b; + background: rgba(92, 67, 25, 0.2); + font-size: 0.76rem; +} + +.upload-lock-notice span { + color: var(--muted-strong); +} + +.upload-lock-notice code { + color: var(--amber); +} + +.character-count { + display: block; + margin-top: 5px; + color: #718096; + font-size: 0.68rem; + text-align: right; +} + +input:disabled, +textarea:disabled { + cursor: not-allowed; + opacity: 0.58; +} + +.file-input:disabled::file-selector-button { + cursor: not-allowed; +} diff --git a/app/app/lib/rag.ts b/app/app/lib/rag.ts index 5210d59..a9af27e 100644 --- a/app/app/lib/rag.ts +++ b/app/app/lib/rag.ts @@ -38,6 +38,13 @@ export interface Stats { ef_construction: number; embedder: string; generation: "mock" | "claude"; + uploads_enabled: boolean; + limits: { + title_chars: number; + document_chars: number; + question_chars: number; + request_bytes: number; + }; } export class RagServiceError extends Error { diff --git a/app/app/page.tsx b/app/app/page.tsx index d1f693a..354fa61 100644 --- a/app/app/page.tsx +++ b/app/app/page.tsx @@ -69,6 +69,11 @@ export default function Home() { const [uploadNotice, setUploadNotice] = useState(null); const [fileInputKey, setFileInputKey] = useState(0); + const uploadsEnabled = stats?.uploads_enabled ?? false; + const titleLimit = stats?.limits.title_chars ?? 200; + const documentLimit = stats?.limits.document_chars ?? 1_000_000; + const questionLimit = stats?.limits.question_chars ?? 2_000; + const loadWorkspace = useCallback(async () => { setWorkspaceLoading(true); setWorkspaceError(null); @@ -113,12 +118,20 @@ export default function Home() { } async function chooseFile(event: ChangeEvent) { + if (!uploadsEnabled) return; const file = event.target.files?.[0]; if (!file) return; setUploadError(null); try { const text = await file.text(); + if (text.length > documentLimit) { + setUploadError( + `Document exceeds the ${documentLimit.toLocaleString()} character limit.`, + ); + event.target.value = ""; + return; + } setDocumentText(text); if (!documentTitle.trim()) { setDocumentTitle(file.name.replace(/\.(md|markdown|txt)$/i, "")); @@ -130,7 +143,7 @@ export default function Home() { async function uploadDocument(event: FormEvent) { event.preventDefault(); - if (!documentTitle.trim() || !documentText.trim()) return; + if (!uploadsEnabled || !documentTitle.trim() || !documentText.trim()) return; setUploadLoading(true); setUploadError(null); @@ -239,12 +252,16 @@ export default function Home() {