mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
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:
1 parent
896ba233da
commit
176f21ab78
10 files changed
+111
-16
No files matched your search
@@ -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).
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -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
@@ -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 };
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in new issue
Block a user