Skip to content
13 changes: 13 additions & 0 deletions .changeset/provider-input-sanitisation.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
goastler marked this conversation as resolved.

- 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).
7 changes: 4 additions & 3 deletions packages/provider/src/api/captcha/checkSpamEmail.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,19 @@
// limitations under the License.

import { ProsopoApiError } from "@prosopo/common";
import { INPUT_LIMITS, boundedString } from "@prosopo/types";
import type { ProviderEnvironment } from "@prosopo/types-env";
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),
});
Comment thread
goastler marked this conversation as resolved.

export default (env: ProviderEnvironment) =>
Expand Down
6 changes: 5 additions & 1 deletion packages/provider/src/api/startProviderApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,11 @@ export async function startProviderApi(
maxAge: 86400,
}),
);
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));
Expand Down
1 change: 1 addition & 0 deletions packages/types/src/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@
export * from "./api.js";
export * from "./params.js";
export * from "./ipapi.js";
export * from "./sanitise.js";
82 changes: 82 additions & 0 deletions packages/types/src/api/sanitise.ts
Original file line number Diff line number Diff line change
@@ -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",
);
Loading
Loading