fix(fhir): bound _offset and answer a larger one with 400

An offset beyond what the database can skip reached Prisma unchecked
and answered 500. Every search route now refuses an _offset above
100000 with an invalid OperationOutcome.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 00:01:27 +02:00
1 parent 896ba233da
commit 176f21ab78
10 files changed
+111 -16

No files matched your search

+12
View File
@@ -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).
+5 -1
View File
@@ -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(
@@ -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, {
@@ -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, {
+5 -1
View File
@@ -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) {
@@ -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(
+5 -1
View File
@@ -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)];
+17
View File
@@ -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
+16 -4
View File
@@ -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 };
+36 -6
View File
@@ -376,7 +376,12 @@ export const healthRecordPaths: NonNullable<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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<ZodOpenApiObject["paths"]> = {
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: {