diff --git a/docs/api/openapi.yaml b/docs/api/openapi.yaml index ecc92d47d..808c289bc 100644 --- a/docs/api/openapi.yaml +++ b/docs/api/openapi.yaml @@ -1274,7 +1274,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1310,7 +1312,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. - in: query name: date schema: @@ -1345,7 +1349,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1372,7 +1378,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1400,7 +1408,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). @@ -1595,7 +1605,9 @@ paths: - in: query name: _offset schema: + description: At most 100000; a larger offset is a 400 OperationOutcome. type: number + description: At most 100000; a larger offset is a 400 OperationOutcome. responses: "200": description: searchset Bundle (application/fhir+json). diff --git a/src/app/api/fhir/Coverage/route.ts b/src/app/api/fhir/Coverage/route.ts index ce45cbc0f..de38c6414 100644 --- a/src/app/api/fhir/Coverage/route.ts +++ b/src/app/api/fhir/Coverage/route.ts @@ -43,7 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = [coverageResource(context.data, context.identity)].filter( diff --git a/src/app/api/fhir/MedicationAdministration/route.ts b/src/app/api/fhir/MedicationAdministration/route.ts index 361d1eed6..639472eba 100644 --- a/src/app/api/fhir/MedicationAdministration/route.ts +++ b/src/app/api/fhir/MedicationAdministration/route.ts @@ -41,7 +41,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = medicationAdministrationsFromReportData(context.data, { diff --git a/src/app/api/fhir/MedicationStatement/route.ts b/src/app/api/fhir/MedicationStatement/route.ts index bd205854c..848f6562d 100644 --- a/src/app/api/fhir/MedicationStatement/route.ts +++ b/src/app/api/fhir/MedicationStatement/route.ts @@ -41,7 +41,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = medicationStatementsFromReportData(context.data, { diff --git a/src/app/api/fhir/Observation/route.ts b/src/app/api/fhir/Observation/route.ts index 1988f78c4..8267b28d3 100644 --- a/src/app/api/fhir/Observation/route.ts +++ b/src/app/api/fhir/Observation/route.ts @@ -57,7 +57,11 @@ export const GET = apiHandler(async (request: NextRequest) => { return operationOutcome(429, "throttled", "Rate limit exceeded"); } const params = request.nextUrl.searchParams; - const { count, offset } = parsePaging(params); + const paging = parsePaging(params); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const codeValues = params.getAll("code"); const range = parseDateParams(params.getAll("date")); if (range && "error" in range) { diff --git a/src/app/api/fhir/Patient/$everything/route.ts b/src/app/api/fhir/Patient/$everything/route.ts index 68faf6304..bebc9d12c 100644 --- a/src/app/api/fhir/Patient/$everything/route.ts +++ b/src/app/api/fhir/Patient/$everything/route.ts @@ -69,7 +69,11 @@ export const GET = apiHandler(async (request: NextRequest) => { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const { data, identity, germanAtc, records } = await loadFhirContext(user.id); const document = buildFhirDocumentBundle( diff --git a/src/app/api/fhir/Patient/route.ts b/src/app/api/fhir/Patient/route.ts index dcf03b802..27a8b90f0 100644 --- a/src/app/api/fhir/Patient/route.ts +++ b/src/app/api/fhir/Patient/route.ts @@ -43,7 +43,11 @@ export const GET = apiHandler(async (request: NextRequest) => { if (!rl.allowed) { return operationOutcome(429, "throttled", "Rate limit exceeded"); } - const { count, offset } = parsePaging(request.nextUrl.searchParams); + const paging = parsePaging(request.nextUrl.searchParams); + if ("error" in paging) { + return operationOutcome(400, "invalid", paging.error); + } + const { count, offset } = paging; const context = await loadFhirContext(user.id); const all = [patientResource(context.data, context.identity)]; diff --git a/src/app/api/fhir/__tests__/route.test.ts b/src/app/api/fhir/__tests__/route.test.ts index af4bdfe81..eaf538a41 100644 --- a/src/app/api/fhir/__tests__/route.test.ts +++ b/src/app/api/fhir/__tests__/route.test.ts @@ -147,6 +147,23 @@ describe("GET /api/fhir/Observation — searchset", () => { expect(bundle.entry[0].resource.id).toBe("obs-3"); }); + it("refuses an offset past the ceiling with a 400 OperationOutcome", async () => { + vi.mocked(observationsFromReportData).mockReturnValue([obs(1)] as never); + for (const offset of ["100001", "1e20", "Infinity"]) { + const res = await observationGet(req(`?_offset=${offset}`)); + expect(res.status).toBe(400); + const outcome = (await res.json()) as { + resourceType: string; + issue: Array<{ code: string }>; + }; + expect(outcome.resourceType).toBe("OperationOutcome"); + expect(outcome.issue[0].code).toBe("invalid"); + } + // The ceiling itself still pages (an empty page past the end). + const atCeiling = await observationGet(req("?_offset=100000")); + expect(atCeiling.status).toBe(200); + }); + it("enforces the fhir:read scope (Bearer narrow token without it 403s)", async () => { // No session → Bearer path. requireAuth(FHIR_READ_SCOPE) must reject a // token lacking the scope; here no auth at all yields 401, proving the diff --git a/src/lib/fhir/rest.ts b/src/lib/fhir/rest.ts index 75e87ecf2..ebd1c54e3 100644 --- a/src/lib/fhir/rest.ts +++ b/src/lib/fhir/rest.ts @@ -91,21 +91,33 @@ export const DEFAULT_COUNT = 50; /** Hard ceiling on `_count` so a single search can never page the whole store. */ export const MAX_COUNT = 200; +/** + * Ceiling on `_offset`. No record pages that far (the largest search, the + * Observation time series, is bounded by the readings in a date range), and + * an unbounded offset reached the database as a skip it cannot represent and + * answered 500. + */ +export const MAX_OFFSET = 100_000; + /** * Parsed + clamped paging parameters. `count` is bounded to `[1, MAX_COUNT]`; * `offset` floors at 0. A non-numeric / negative input collapses to the * default rather than erroring — FHIR search is forgiving on paging params. + * An offset above {@link MAX_OFFSET} is the one refusal: the caller answers + * it with a 400 `invalid` OperationOutcome. */ -export function parsePaging(searchParams: URLSearchParams): { - count: number; - offset: number; -} { +export function parsePaging( + searchParams: URLSearchParams, +): { count: number; offset: number } | { error: string } { const rawCount = Number(searchParams.get("_count")); const count = Number.isFinite(rawCount) && rawCount > 0 ? Math.min(Math.floor(rawCount), MAX_COUNT) : DEFAULT_COUNT; const rawOffset = Number(searchParams.get("_offset")); + if (rawOffset > MAX_OFFSET) { + return { error: `_offset must be at most ${MAX_OFFSET}` }; + } const offset = Number.isFinite(rawOffset) && rawOffset > 0 ? Math.floor(rawOffset) : 0; return { count, offset }; diff --git a/src/lib/openapi/routes/health-record.ts b/src/lib/openapi/routes/health-record.ts index 9673e3aa3..20e0e4042 100644 --- a/src/lib/openapi/routes/health-record.ts +++ b/src/lib/openapi/routes/health-record.ts @@ -376,7 +376,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -401,7 +406,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), date: z.string().optional(), code: z.string().optional(), }), @@ -428,7 +438,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -453,7 +468,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -478,7 +498,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: { @@ -653,7 +678,12 @@ export const healthRecordPaths: NonNullable = { requestParams: { query: z.object({ _count: z.coerce.number().optional(), - _offset: z.coerce.number().optional(), + _offset: z.coerce + .number() + .optional() + .describe( + "At most 100000; a larger offset is a 400 OperationOutcome.", + ), }), }, responses: {