From ed8c7cc2a3c8b307318b812cfdbaa9850e23ed8f Mon Sep 17 00:00:00 2001 From: George Oastler Date: Wed, 17 Jun 2026 00:42:45 +0100 Subject: [PATCH 1/5] fix(provider): length-bound and sanitise request inputs across endpoints Add shared zod helpers in @prosopo/types (INPUT_LIMITS, boundedString, safeText, safeLine) and apply length bounds across the provider API request schemas (captcha challenge/solution, frictionless, server verify, DNS event ingestion, sitekey + decision-machine admin bodies, spam-email check). Lower the express.json body cap from 50mb to 1mb as a coarse oversized-payload backstop. --- .changeset/provider-input-sanitisation.md | 13 ++ .../src/api/captcha/checkSpamEmail.ts | 7 +- packages/provider/src/api/startProviderApi.ts | 6 +- packages/types/src/api/index.ts | 1 + packages/types/src/api/sanitise.ts | 82 ++++++++++ packages/types/src/provider/api.ts | 148 +++++++++--------- 6 files changed, 181 insertions(+), 76 deletions(-) create mode 100644 .changeset/provider-input-sanitisation.md create mode 100644 packages/types/src/api/sanitise.ts diff --git a/.changeset/provider-input-sanitisation.md b/.changeset/provider-input-sanitisation.md new file mode 100644 index 0000000000..83d20dc90e --- /dev/null +++ b/.changeset/provider-input-sanitisation.md @@ -0,0 +1,13 @@ +--- +"@prosopo/types": patch +"@prosopo/provider": patch +--- + +fix(provider): length-bound and sanitise request inputs across the provider API endpoints. + +- Add shared zod helpers in `@prosopo/types` (`INPUT_LIMITS`, `boundedString`, `safeText`, `safeLine`): every request string field is now length-bounded, and human freetext additionally rejects control characters (null bytes etc.). Typing fields as strings already blocks Mongo operator injection; the control-character rejection covers the remaining log/header-injection vectors. +- Apply the helpers across the provider request schemas (image/pow/puzzle captcha challenge & solution bodies, frictionless challenge, server verify, DNS event ingestion, sitekey register/remove, detector-key and decision-machine admin bodies, and the spam-email check). Tokens, signatures, behavioural/simd readings and decision-machine source get generous caps; accounts/site-keys/hashes/ids get tight ones. + +- Lower the provider API body-parser cap from 50 MB to 1 MB (`express.json` in `startProviderApi.ts`) as a coarse oversized-payload backstop before parsing. + +Email and IP fields are treated as length-bounded strings (email keeps its existing format check where present). diff --git a/packages/provider/src/api/captcha/checkSpamEmail.ts b/packages/provider/src/api/captcha/checkSpamEmail.ts index 09462862a2..8e73ea2a78 100644 --- a/packages/provider/src/api/captcha/checkSpamEmail.ts +++ b/packages/provider/src/api/captcha/checkSpamEmail.ts @@ -14,17 +14,18 @@ import { ProsopoApiError } from "@prosopo/common"; import type { ProviderEnvironment } from "@prosopo/types-env"; +import { INPUT_LIMITS, boundedString } from "@prosopo/types"; import { extractDomainFromEmail } from "@prosopo/util"; import type { NextFunction, Request, Response } from "express"; -import { object, string } from "zod"; +import { object } from "zod"; import type { AugmentedRequest } from "../../express.js"; import { Tasks } from "../../tasks/index.js"; import { checkSpamEmail as checkSpamEmailFn } from "../../tasks/spam/checkSpamEmail.js"; import { getMaintenanceMode } from "../admin/apiToggleMaintenanceModeEndpoint.js"; const CheckSpamEmailRequestBody = object({ - email: string(), - dapp: string(), + email: boundedString(INPUT_LIMITS.EMAIL), + dapp: boundedString(INPUT_LIMITS.ID), }); export default (env: ProviderEnvironment) => diff --git a/packages/provider/src/api/startProviderApi.ts b/packages/provider/src/api/startProviderApi.ts index 04b2f7b688..ed8582c3cc 100644 --- a/packages/provider/src/api/startProviderApi.ts +++ b/packages/provider/src/api/startProviderApi.ts @@ -212,7 +212,11 @@ export async function startProviderApi( // for the honeypot transport. Same effect as the // Access-Control-Expose-Headers response header. apiApp.use(cors({ exposedHeaders: ["x-prosopo-meta"] })); - apiApp.use(express.json({ limit: "50mb" })); + // Coarse request body-size backstop. Generous enough for legitimate + // payloads (captcha solutions, behavioural/simd readings, DNS event + // batches) but bounds oversized-payload abuse before parsing; the + // per-field caps in @prosopo/types (`INPUT_LIMITS`) are the finer control. + apiApp.use(express.json({ limit: "1mb" })); // Put this first so that no middleware runs on it apiApp.use(publicRouter(env)); diff --git a/packages/types/src/api/index.ts b/packages/types/src/api/index.ts index 2ea2bb7469..2eeba312d6 100644 --- a/packages/types/src/api/index.ts +++ b/packages/types/src/api/index.ts @@ -14,3 +14,4 @@ export * from "./api.js"; export * from "./params.js"; export * from "./ipapi.js"; +export * from "./sanitise.js"; diff --git a/packages/types/src/api/sanitise.ts b/packages/types/src/api/sanitise.ts new file mode 100644 index 0000000000..e07b6e4b33 --- /dev/null +++ b/packages/types/src/api/sanitise.ts @@ -0,0 +1,82 @@ +// Copyright 2021-2026 Prosopo (UK) Ltd. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { string } from "zod"; + +/** + * Centralised input length limits for request payloads. Generous enough not to + * reject legitimate input, but bounded so an oversized field cannot bloat + * storage, logs, downstream API calls, or act as a cheap DoS vector. The + * express body-size cap (provider startProviderApi.ts) is the coarse backstop; + * these are the per-field limits. + */ +export const INPUT_LIMITS = { + /** Identifiers, keys, slugs (accounts, site keys, dataset ids, …). */ + ID: 256, + /** Names, labels, titles. */ + NAME: 256, + /** Email addresses (treated as opaque strings — no format validation). */ + EMAIL: 320, + /** URLs. */ + URL: 2048, + /** General short freetext. Default for `boundedString` / `safeText`. */ + TEXT: 16384, + /** Longer freetext: messages, descriptions, decision-machine source. */ + LONG_TEXT: 65536, + /** Tokens, signatures, base64 payloads, behavioural/simd readings. */ + TOKEN: 131072, +} as const; + +// Anchored negated character classes: a string is valid only if it contains +// NONE of these. Implemented as a `.regex()` (rather than `.refine()`) so the +// result stays a `ZodString` and callers can still chain `.min()` / `.max()`. +// C0 control chars + DEL (U+007F) are rejected; tab/newline/carriage-return are +// allowed in safeText. C1 (U+0080–U+009F) is intentionally not rejected, to +// avoid false positives on legitimate international text. +// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars in order to reject them +const NO_CONTROL_CHARS = /^[^\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]*$/; +// Single-line variant: additionally rejects tab/CR/LF. +// biome-ignore lint/suspicious/noControlCharactersInRegex: deliberately matching control chars in order to reject them +const NO_CONTROL_OR_NEWLINE = /^[^\u0000-\u001f\u007f]*$/; + +/** + * A length-bounded string. Use for structured values (ids, keys, tokens) where + * the character set is already constrained by format. + */ +export const boundedString = (max: number = INPUT_LIMITS.TEXT) => + string().max(max); + +/** + * Length-bounded freetext that rejects control characters (null bytes etc.). + * Use for human-entered, multi-line text (messages, descriptions). Typing the + * field as a string already blocks Mongo operator injection (an object such as + * `{$gt:…}` fails the string check); this additionally rejects the control + * characters used for log/terminal injection. + */ +export const safeText = (max: number = INPUT_LIMITS.TEXT) => + string() + .max(max) + .regex(NO_CONTROL_CHARS, "must not contain control characters"); + +/** + * Single-line variant of {@link safeText}: also rejects line breaks. Use for + * short freetext that flows into headers/subjects (names, titles) to prevent + * header injection. + */ +export const safeLine = (max: number = INPUT_LIMITS.NAME) => + string() + .max(max) + .regex( + NO_CONTROL_OR_NEWLINE, + "must not contain control characters or line breaks", + ); diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 8231036980..bf6e276fc7 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -32,6 +32,7 @@ import { type infer as zInfer, } from "zod"; import { ApiParams } from "../api/params.js"; +import { INPUT_LIMITS, boundedString } from "../api/sanitise.js"; import { type CaptchaType, DecisionMachineCaptchaTypeSchema, @@ -259,11 +260,14 @@ export interface CaptchaIdAndProof { } export const CaptchaRequestBody = object({ - [ApiParams.user]: string(), - [ApiParams.dapp]: string(), - [ApiParams.datasetId]: union([string(), array(number())]), - [ApiParams.sessionId]: string().optional(), - [ApiParams.simdReadings]: string().optional(), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), + [ApiParams.datasetId]: union([ + boundedString(INPUT_LIMITS.ID), + array(number()), + ]), + [ApiParams.sessionId]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); export type CaptchaRequestBodyType = zInfer; @@ -284,24 +288,24 @@ export interface CaptchaResponseBody extends ApiResponse { // record, no automatic verdict. The TS shape (`ClientMetaData`) lives in // ./database.ts — this schema is the wire-level zod for request bodies. export const ClientMetaDataSchema = object({ - [ApiParams.hp]: string().optional(), + [ApiParams.hp]: boundedString(INPUT_LIMITS.TEXT).optional(), }); export const CaptchaSolutionBody = object({ - [ApiParams.user]: string(), - [ApiParams.dapp]: string(), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), [ApiParams.captchas]: array(CaptchaSolutionSchema), - [ApiParams.requestHash]: string(), - [ApiParams.timestamp]: string(), + [ApiParams.requestHash]: boundedString(INPUT_LIMITS.ID), + [ApiParams.timestamp]: boundedString(INPUT_LIMITS.ID), [ApiParams.signature]: object({ [ApiParams.user]: TimestampSignatureSchema, [ApiParams.provider]: RequestHashSignatureSchema, }), - [ApiParams.behavioralData]: string().optional(), + [ApiParams.behavioralData]: boundedString(INPUT_LIMITS.TOKEN).optional(), // Compact encoded SimdReadings produced by @prosopo/catcher's // simdReadingsCodec — opaque at this layer; the provider decodes and // persists on the captcha record. Collection-only, no scoring. - [ApiParams.simdReadings]: string().optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), [ApiParams.clientMetaData]: ClientMetaDataSchema.optional(), }); @@ -309,12 +313,12 @@ export type CaptchaSolutionBodyType = zInfer; export const VerifySolutionBody = object({ [ApiParams.token]: ProcaptchaTokenSpec, - [ApiParams.dappSignature]: string(), + [ApiParams.dappSignature]: boundedString(INPUT_LIMITS.TOKEN), [ApiParams.maxVerifiedTime]: number() .optional() .default(DEFAULT_IMAGE_MAX_VERIFIED_TIME_CACHED), - [ApiParams.ip]: string().optional(), - [ApiParams.email]: string().optional(), + [ApiParams.ip]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.email]: boundedString(INPUT_LIMITS.EMAIL).optional(), }); export type VerifySolutionBodyTypeInput = input; @@ -416,9 +420,9 @@ export interface PowCaptchaSolutionResponse extends ApiResponse { */ export const ServerPowCaptchaVerifyRequestBody = object({ [ApiParams.token]: ProcaptchaTokenSpec, - [ApiParams.dappSignature]: string(), - [ApiParams.ip]: string().optional(), - [ApiParams.email]: string().email().optional(), + [ApiParams.dappSignature]: boundedString(INPUT_LIMITS.TOKEN), + [ApiParams.ip]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.email]: boundedString(INPUT_LIMITS.EMAIL).email().optional(), }); export type ServerPowCaptchaVerifyRequestBodyOutput = output< @@ -434,19 +438,19 @@ export type DnsEventKind = "dns" | "http"; export const DnsEventSchema = object({ kind: DnsEventKindSchema, - ts: string(), // ISO-8601 UTC (serde default for chrono DateTime) - src_ip: string(), + ts: boundedString(INPUT_LIMITS.ID), // ISO-8601 UTC (serde default for chrono DateTime) + src_ip: boundedString(INPUT_LIMITS.ID), // Per-session ID carried in the URL subdomain — captures the procaptcha // sessionId. Named `jti` on the wire for cross-product compatibility // with Protect's session identifier. - jti: string().optional(), - site_key: string().optional(), - subzone: string().optional(), - qname: string().optional(), - qtype: string().optional(), - sni: string().optional(), - path: string().optional(), - user_agent: string().optional(), + jti: boundedString(INPUT_LIMITS.ID).optional(), + site_key: boundedString(INPUT_LIMITS.ID).optional(), + subzone: boundedString(INPUT_LIMITS.ID).optional(), + qname: boundedString(INPUT_LIMITS.ID).optional(), + qtype: boundedString(INPUT_LIMITS.ID).optional(), + sni: boundedString(INPUT_LIMITS.ID).optional(), + path: boundedString(INPUT_LIMITS.URL).optional(), + user_agent: boundedString(INPUT_LIMITS.TEXT).optional(), path_valid: boolean().optional(), }); export type DnsEvent = output; @@ -462,10 +466,10 @@ export interface DnsEventResponseBody extends ApiResponse { } export const GetPowCaptchaChallengeRequestBody = object({ - [ApiParams.user]: string(), - [ApiParams.dapp]: string(), - [ApiParams.sessionId]: string().optional(), - [ApiParams.simdReadings]: string().optional(), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), + [ApiParams.sessionId]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); export type GetPowCaptchaChallengeRequestBodyType = zInfer< @@ -485,18 +489,18 @@ export const SubmitPowCaptchaSolutionBody = object({ [ApiParams.difficulty]: number(), [ApiParams.signature]: object({ [ApiParams.user]: object({ - [ApiParams.timestamp]: string(), + [ApiParams.timestamp]: boundedString(INPUT_LIMITS.ID), }), [ApiParams.provider]: object({ - [ApiParams.challenge]: string(), + [ApiParams.challenge]: boundedString(INPUT_LIMITS.TOKEN), }), }), - [ApiParams.user]: string(), - [ApiParams.dapp]: string(), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), [ApiParams.nonce]: number(), - [ApiParams.behavioralData]: string().optional(), - [ApiParams.salt]: string().optional(), - [ApiParams.simdReadings]: string().optional(), + [ApiParams.behavioralData]: boundedString(INPUT_LIMITS.TOKEN).optional(), + [ApiParams.salt]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), [ApiParams.clientMetaData]: ClientMetaDataSchema.optional(), }); @@ -505,12 +509,12 @@ export type SubmitPowCaptchaSolutionBodyType = input< >; export const GetFrictionlessCaptchaChallengeRequestBody = object({ - [ApiParams.dapp]: string(), - [ApiParams.token]: string(), - [ApiParams.user]: string(), - [ApiParams.headHash]: string(), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), + [ApiParams.token]: boundedString(INPUT_LIMITS.TOKEN), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.headHash]: boundedString(INPUT_LIMITS.ID), [ApiParams.mode]: nativeEnum(ModeEnum).optional(), - [ApiParams.simdReadings]: string().optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); export type GetFrictionlessCaptchaChallengeRequestBodyOutput = output< @@ -524,10 +528,10 @@ export type SubmitPowCaptchaSolutionBodyTypeOutput = output< // Puzzle captcha schemas export const GetPuzzleCaptchaChallengeRequestBody = object({ - [ApiParams.user]: string(), - [ApiParams.dapp]: string(), - [ApiParams.sessionId]: string().optional(), - [ApiParams.simdReadings]: string().optional(), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), + [ApiParams.sessionId]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); export type GetPuzzleCaptchaChallengeRequestBodyType = zInfer< @@ -556,17 +560,17 @@ export const SubmitPuzzleCaptchaSolutionBody = object({ [ApiParams.puzzleEvents]: array(PuzzleEventSchema), [ApiParams.signature]: object({ [ApiParams.user]: object({ - [ApiParams.timestamp]: string(), + [ApiParams.timestamp]: boundedString(INPUT_LIMITS.ID), }), [ApiParams.provider]: object({ - [ApiParams.challenge]: string(), + [ApiParams.challenge]: boundedString(INPUT_LIMITS.TOKEN), }), }), - [ApiParams.user]: string(), - [ApiParams.dapp]: string(), - [ApiParams.behavioralData]: string().optional(), - [ApiParams.salt]: string().optional(), - [ApiParams.simdReadings]: string().optional(), + [ApiParams.user]: boundedString(INPUT_LIMITS.ID), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), + [ApiParams.behavioralData]: boundedString(INPUT_LIMITS.TOKEN).optional(), + [ApiParams.salt]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), [ApiParams.clientMetaData]: ClientMetaDataSchema.optional(), }); @@ -580,9 +584,9 @@ export type SubmitPuzzleCaptchaSolutionBodyTypeOutput = output< export const ServerPuzzleCaptchaVerifyRequestBody = object({ [ApiParams.token]: ProcaptchaTokenSpec, - [ApiParams.dappSignature]: string(), - [ApiParams.ip]: string().optional(), - [ApiParams.email]: string().email().optional(), + [ApiParams.dappSignature]: boundedString(INPUT_LIMITS.TOKEN), + [ApiParams.ip]: boundedString(INPUT_LIMITS.ID).optional(), + [ApiParams.email]: boundedString(INPUT_LIMITS.EMAIL).email().optional(), }); export type ServerPuzzleCaptchaVerifyRequestBodyType = zInfer< @@ -594,65 +598,65 @@ export type ServerPuzzleCaptchaVerifyRequestBodyOutput = output< >; export const VerifyPowCaptchaSolutionBody = object({ - [ApiParams.siteKey]: string(), + [ApiParams.siteKey]: boundedString(INPUT_LIMITS.ID), }); export const RegisterSitekeyBody = object({ - [ApiParams.siteKey]: string(), + [ApiParams.siteKey]: boundedString(INPUT_LIMITS.ID), [ApiParams.tier]: nativeEnum(Tier), [ApiParams.settings]: ClientSettingsSchema.optional(), }); export const RegisterSitekeysBody = array( object({ - [ApiParams.siteKey]: string(), + [ApiParams.siteKey]: boundedString(INPUT_LIMITS.ID), [ApiParams.tier]: nativeEnum(Tier), [ApiParams.settings]: ClientSettingsSchema.optional(), }), ); export const RemoveSitekeyBody = object({ - [ApiParams.siteKey]: string(), + [ApiParams.siteKey]: boundedString(INPUT_LIMITS.ID), }); export const RemoveSitekeysBody = array( object({ - [ApiParams.siteKey]: string(), + [ApiParams.siteKey]: boundedString(INPUT_LIMITS.ID), }), ); export const UpdateDetectorKeyBody = object({ - [ApiParams.detectorKey]: string(), + [ApiParams.detectorKey]: boundedString(INPUT_LIMITS.TOKEN), }); export const UpdateDecisionMachineBody = object({ [ApiParams.decisionMachineScope]: nativeEnum(DecisionMachineScope), [ApiParams.decisionMachineRuntime]: nativeEnum(DecisionMachineRuntime), - [ApiParams.decisionMachineSource]: string(), + [ApiParams.decisionMachineSource]: boundedString(INPUT_LIMITS.LONG_TEXT), [ApiParams.decisionMachineLanguage]: nativeEnum( DecisionMachineLanguage, ).optional(), - [ApiParams.decisionMachineName]: string().optional(), - [ApiParams.decisionMachineVersion]: string().optional(), + [ApiParams.decisionMachineName]: boundedString(INPUT_LIMITS.NAME).optional(), + [ApiParams.decisionMachineVersion]: boundedString(INPUT_LIMITS.ID).optional(), [ApiParams.decisionMachineCaptchaType]: DecisionMachineCaptchaTypeSchema.optional(), - [ApiParams.dapp]: string().optional(), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID).optional(), }); export const GetDecisionMachineBody = object({ - id: string(), + id: boundedString(INPUT_LIMITS.ID), }); export const GetAllDecisionMachinesBody = object({}); export const RemoveDecisionMachineBody = object({ - id: string(), + id: boundedString(INPUT_LIMITS.ID), }); export const RemoveAllDecisionMachinesBody = object({}); export const ClearAllCountersBody = object({ - [ApiParams.dapp]: string().optional(), + [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID).optional(), }); export type ClearAllCountersBodyType = z.infer; @@ -719,7 +723,7 @@ export type RemoveAllDecisionMachinesResponseType = z.infer< >; export const RemoveDetectorKeyBodySpec = object({ - [ApiParams.detectorKey]: string(), + [ApiParams.detectorKey]: boundedString(INPUT_LIMITS.TOKEN), [ApiParams.expirationInSeconds]: number().positive().optional(), }); From 7029f0a18eb9d5b36b59e4b716a034562427d001 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Tue, 23 Jun 2026 09:10:27 +0100 Subject: [PATCH 2/5] fix(provider): bound nested request schemas and fix import order Address review feedback: length-bound ProcaptchaTokenSpec, captcha solution fields, and signature schemas at the request-body level so oversized nested payloads are rejected. Fix biome import ordering in checkSpamEmail. --- packages/types/src/provider/api.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 24d6a46cee..6360f05ae5 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -54,9 +54,9 @@ import { DecisionMachineRuntime, DecisionMachineScope, } from "../decisionMachine/index.js"; -import { - type ChallengeSignature, - type RequestHashSignature, +import type { + ChallengeSignature, + RequestHashSignature, } from "../procaptcha/index.js"; export type ApiJsonError = { From dd0754923e0e533f17e2a7b4c34f80c52df97757 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Tue, 23 Jun 2026 12:30:10 +0100 Subject: [PATCH 3/5] fix(provider): length-bound fingerprintProof in SubmitPowCaptchaSolutionBody --- packages/types/src/provider/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 6360f05ae5..3c4c77102a 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -517,7 +517,7 @@ export const SubmitPowCaptchaSolutionBody = object({ [ApiParams.salt]: boundedString(INPUT_LIMITS.ID).optional(), [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), [ApiParams.clientMetaData]: ClientMetaDataSchema.optional(), - [ApiParams.fingerprintProof]: string().optional(), + [ApiParams.fingerprintProof]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); export type SubmitPowCaptchaSolutionBodyType = input< From 509729ee8012a1bf8e190af880ed9a02424e2ee4 Mon Sep 17 00:00:00 2001 From: George Oastler Date: Tue, 23 Jun 2026 12:35:31 +0100 Subject: [PATCH 4/5] fix(provider): widen headHash bound to TOKEN so frictionless challenge accepts real fingerprint hashes --- packages/types/src/provider/api.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 3c4c77102a..20fd5dce73 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -528,7 +528,7 @@ export const GetFrictionlessCaptchaChallengeRequestBody = object({ [ApiParams.dapp]: boundedString(INPUT_LIMITS.ID), [ApiParams.token]: boundedString(INPUT_LIMITS.TOKEN), [ApiParams.user]: boundedString(INPUT_LIMITS.ID), - [ApiParams.headHash]: boundedString(INPUT_LIMITS.ID), + [ApiParams.headHash]: boundedString(INPUT_LIMITS.TOKEN), [ApiParams.mode]: nativeEnum(ModeEnum).optional(), [ApiParams.simdReadings]: boundedString(INPUT_LIMITS.TOKEN).optional(), }); From 997a2dbe78a56d9e997b54b5094a77856aacc36e Mon Sep 17 00:00:00 2001 From: George Oastler Date: Tue, 23 Jun 2026 17:51:42 +0100 Subject: [PATCH 5/5] fix(provider): use safeText/safeLine for freetext & header-derived fields to reject control chars honeypot text and decision-machine source -> safeText; user_agent, decision-machine name, spam-check email/dapp -> safeLine. Matches the changeset's control-character-rejection claim. --- .../provider/src/api/captcha/checkSpamEmail.ts | 6 +++--- packages/types/src/provider/api.ts | 15 ++++++++++----- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/packages/provider/src/api/captcha/checkSpamEmail.ts b/packages/provider/src/api/captcha/checkSpamEmail.ts index 093b340231..55cc621606 100644 --- a/packages/provider/src/api/captcha/checkSpamEmail.ts +++ b/packages/provider/src/api/captcha/checkSpamEmail.ts @@ -13,7 +13,7 @@ // limitations under the License. import { ProsopoApiError } from "@prosopo/common"; -import { INPUT_LIMITS, boundedString } from "@prosopo/types"; +import { INPUT_LIMITS, safeLine } from "@prosopo/types"; import type { ProviderEnvironment } from "@prosopo/types-env"; import { extractDomainFromEmail } from "@prosopo/util"; import type { NextFunction, Request, Response } from "express"; @@ -24,8 +24,8 @@ import { checkSpamEmail as checkSpamEmailFn } from "../../tasks/spam/checkSpamEm import { getMaintenanceMode } from "../admin/apiToggleMaintenanceModeEndpoint.js"; const CheckSpamEmailRequestBody = object({ - email: boundedString(INPUT_LIMITS.EMAIL), - dapp: boundedString(INPUT_LIMITS.ID), + email: safeLine(INPUT_LIMITS.EMAIL), + dapp: safeLine(INPUT_LIMITS.ID), }); export default (env: ProviderEnvironment) => diff --git a/packages/types/src/provider/api.ts b/packages/types/src/provider/api.ts index 20fd5dce73..6c2c47f97e 100644 --- a/packages/types/src/provider/api.ts +++ b/packages/types/src/provider/api.ts @@ -32,7 +32,12 @@ import { type infer as zInfer, } from "zod"; import { ApiParams } from "../api/params.js"; -import { INPUT_LIMITS, boundedString } from "../api/sanitise.js"; +import { + INPUT_LIMITS, + boundedString, + safeLine, + safeText, +} from "../api/sanitise.js"; import { type CaptchaType, DecisionMachineCaptchaTypeSchema, @@ -284,7 +289,7 @@ export interface CaptchaResponseBody extends ApiResponse { // record, no automatic verdict. The TS shape (`ClientMetaData`) lives in // ./database.ts — this schema is the wire-level zod for request bodies. export const ClientMetaDataSchema = object({ - [ApiParams.hp]: boundedString(INPUT_LIMITS.TEXT).optional(), + [ApiParams.hp]: safeText(INPUT_LIMITS.TEXT).optional(), }); // Request-body-level bounded variants of shared schemas. The shared schemas @@ -465,7 +470,7 @@ export const DnsEventSchema = object({ qtype: boundedString(INPUT_LIMITS.ID).optional(), sni: boundedString(INPUT_LIMITS.ID).optional(), path: boundedString(INPUT_LIMITS.URL).optional(), - user_agent: boundedString(INPUT_LIMITS.TEXT).optional(), + user_agent: safeLine(INPUT_LIMITS.TEXT).optional(), path_valid: boolean().optional(), }); export type DnsEvent = output; @@ -648,11 +653,11 @@ export const UpdateDetectorKeyBody = object({ export const UpdateDecisionMachineBody = object({ [ApiParams.decisionMachineScope]: nativeEnum(DecisionMachineScope), [ApiParams.decisionMachineRuntime]: nativeEnum(DecisionMachineRuntime), - [ApiParams.decisionMachineSource]: boundedString(INPUT_LIMITS.LONG_TEXT), + [ApiParams.decisionMachineSource]: safeText(INPUT_LIMITS.LONG_TEXT), [ApiParams.decisionMachineLanguage]: nativeEnum( DecisionMachineLanguage, ).optional(), - [ApiParams.decisionMachineName]: boundedString(INPUT_LIMITS.NAME).optional(), + [ApiParams.decisionMachineName]: safeLine(INPUT_LIMITS.NAME).optional(), [ApiParams.decisionMachineVersion]: boundedString(INPUT_LIMITS.ID).optional(), [ApiParams.decisionMachineCaptchaType]: DecisionMachineCaptchaTypeSchema.optional(),