security(er): validate internal ER bridge thresholds and scope

This commit is contained in:
2026-09-07 17:22:05 +02:00
parent 09e3a01acd
commit 351afc54e1
+47 -38
View File
@@ -9,17 +9,8 @@ import {
import { siaxErrorResponse } from "@/src/http/siax-error-response";
import { SiaxError } from "@/src/platform/errors";
/**
* One canonical ER engine for the legacy Python order pipeline.
*
* The route is intentionally internal-only. Python may shape records and apply
* merge output, but it may not score or decide identity. That decision belongs
* to EntityResolutionService here.
*/
const er = new EntityResolutionService();
// Company-pipeline hard rules. Exact stable identifiers dominate fuzzy scores,
// including explicit negative evidence when both records carry conflicting IDs.
er.registerHardRule({
rule_id: "0pt.company.lei.v1",
decide(a, b) {
@@ -29,6 +20,7 @@ er.registerHardRule({
return av === bv ? "force_match" : "force_non_match";
},
});
er.registerHardRule({
rule_id: "0pt.company.org_nr.v1",
decide(a, b) {
@@ -54,23 +46,41 @@ function assertInternal(request: NextRequest): NextResponse | undefined {
);
}
function finiteThreshold(value: unknown, field: string, fallback: number): number {
if (value === undefined) return fallback;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `${field} must be a finite number between 0 and 1`);
}
return parsed;
}
function parseRequest(raw: unknown): ErCandidateRequest {
if (!raw || typeof raw !== "object") {
if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "JSON object body is required");
}
const body = raw as Record<string, unknown>;
if (!Array.isArray(body.records)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "records must be an array");
const tenantId = typeof body.tenant_id === "string" ? body.tenant_id.trim() : "";
const projectId = typeof body.project_id === "string" ? body.project_id.trim() : "";
if (!tenantId) throw new SiaxError("INVALID_REQUEST", "0pt.er", "tenant_id is required");
if (!projectId) throw new SiaxError("INVALID_REQUEST", "0pt.er", "project_id is required");
if (!Array.isArray(body.records) || body.records.length < 2 || body.records.length > 5_000) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "records must contain between 2 and 5000 items");
}
if (!Array.isArray(body.blocking_fields) || !body.blocking_fields.every((v) => typeof v === "string")) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "blocking_fields must be a string array");
if (
!Array.isArray(body.blocking_fields) ||
body.blocking_fields.length === 0 ||
!body.blocking_fields.every((v) => typeof v === "string" && v.trim().length > 0)
) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "blocking_fields must be a non-empty string array");
}
if (!Array.isArray(body.compare_fields)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must be an array");
if (!Array.isArray(body.compare_fields) || body.compare_fields.length === 0) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "compare_fields must be a non-empty array");
}
const records: ErRecord[] = body.records.map((value, index) => {
if (!value || typeof value !== "object") {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}] must be an object`);
}
const record = value as Record<string, unknown>;
@@ -80,19 +90,15 @@ function parseRequest(raw: unknown): ErCandidateRequest {
if (!record.fields || typeof record.fields !== "object" || Array.isArray(record.fields)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `records[${index}].fields must be an object`);
}
return {
record_id: record.record_id,
fields: record.fields as ErRecord["fields"],
};
return { record_id: record.record_id.trim(), fields: record.fields as ErRecord["fields"] };
});
const ids = new Set(records.map((record) => record.record_id));
if (ids.size !== records.length) {
if (new Set(records.map((record) => record.record_id)).size !== records.length) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "record_id values must be unique within the request");
}
const compareFields = body.compare_fields.map((value, index) => {
if (!value || typeof value !== "object") {
if (!value || typeof value !== "object" || Array.isArray(value)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}] must be an object`);
}
const item = value as Record<string, unknown>;
@@ -100,25 +106,29 @@ function parseRequest(raw: unknown): ErCandidateRequest {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].field is required`);
}
const weight = item.weight === undefined ? undefined : Number(item.weight);
if (weight !== undefined && (!Number.isFinite(weight) || weight < 0)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].weight must be >= 0`);
if (weight !== undefined && (!Number.isFinite(weight) || weight < 0 || weight > 100)) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", `compare_fields[${index}].weight must be between 0 and 100`);
}
return { field: item.field, ...(weight !== undefined ? { weight } : {}) };
return { field: item.field.trim(), ...(weight !== undefined ? { weight } : {}) };
});
const autoThreshold = finiteThreshold(body.auto_match_threshold, "auto_match_threshold", 0.9);
const reviewThreshold = finiteThreshold(body.review_threshold, "review_threshold", 0.6);
if (reviewThreshold >= autoThreshold) {
throw new SiaxError("INVALID_REQUEST", "0pt.er", "review_threshold must be lower than auto_match_threshold");
}
return {
tenant_id: typeof body.tenant_id === "string" ? body.tenant_id : "",
project_id: typeof body.project_id === "string" ? body.project_id : "",
tenant_id: tenantId,
project_id: projectId,
records,
blocking_fields: body.blocking_fields as string[],
blocking_fields: (body.blocking_fields as string[]).map((value) => value.trim()),
compare_fields: compareFields,
...(body.auto_match_threshold !== undefined
? { auto_match_threshold: Number(body.auto_match_threshold) }
auto_match_threshold: autoThreshold,
review_threshold: reviewThreshold,
...(typeof body.correlation_id === "string" && body.correlation_id.trim()
? { correlation_id: body.correlation_id.trim() }
: {}),
...(body.review_threshold !== undefined
? { review_threshold: Number(body.review_threshold) }
: {}),
...(typeof body.correlation_id === "string" ? { correlation_id: body.correlation_id } : {}),
};
}
@@ -127,8 +137,7 @@ export async function POST(request: NextRequest) {
if (denied) return denied;
try {
const body = parseRequest(await request.json());
return NextResponse.json(er.generateCandidates(body));
return NextResponse.json(er.generateCandidates(parseRequest(await request.json())));
} catch (error: unknown) {
return siaxErrorResponse(error);
}