Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .changeset/device-type-context-entropy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
"@prosopo/types": patch
"@prosopo/types-database": patch
"@prosopo/provider": patch
"@prosopo/cli": patch
---

Context-aware validation buckets by device type, not just webview.

Context-aware validation compares a session's head SimHash against a baseline
for its context. That context was `default | webview`, which puts a phone and
a desktop in the same bucket — and those two emit genuinely different
`<head>`s, so the blended baseline matches neither well. Contexts are now the
device family crossed with the webview flag: `desktop`, `desktop-webview`,
`mobile`, `mobile-webview`, `tablet`, `tablet-webview`.

`desktop-webview` is included deliberately. Desktop webviews are a real and
notably fraudulent population here (see the Twickets desktop-webview rules),
and folding them into the plain `desktop` baseline would let exactly the
traffic we want excluded define what "normal desktop" looks like.

**Classification.** `deviceTypeFromUserAgent` in `@prosopo/types` is a
dependency-free UA classifier, deliberately not ua-parser-js: this module is
imported by the browser bundles, and the off-provider entropy sweep has to
bucket stored sessions *identically* or it writes baselines the decision
machine never looks up. One shared function keeps the two sides in lockstep.
Tablets are matched before phones because an iPad's UA carries a
`Mobile/<build>` token and an Android tablet is exactly "Android without
Mobile". Known gap, documented at the call site: an iPadOS 13+ Safari in
desktop mode identifies as a Mac and lands in `desktop` — nothing in the UA
separates it from a real Mac, and both sides make the same call, which is
what matters for the lookup.

**Back-compat.** `default` and `webview` remain valid `ContextType` members,
so settings already stored against them keep parsing. `expandContexts` maps a
legacy `default` onto the three non-webview families and a legacy `webview`
onto the three webview families, at the threshold they were saved with; an
explicit device entry always wins over the legacy entry covering it. Nothing
downstream of settings parsing branches on the legacy keys, and no data
migration is required.

**Behaviour change.** A request whose context is not configured now skips
context validation instead of borrowing another context's baseline.
Previously, configuring a single context validated *every* request against it
— with six contexts that would measure desktop traffic against a tablet
baseline and reject real users wholesale. `isContextConfigured` is the new
guard; `determineContextType` now takes the raw request UA alongside the
webview flag.

New site-key registrations default to all six device contexts.
14 changes: 7 additions & 7 deletions packages/cli/src/commands/siteKeyRegister.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ import { ProviderEnvironment } from "@prosopo/env";
import { LogLevel, type Logger, getLogger } from "@prosopo/logger";
import { Tasks } from "@prosopo/provider";
import {
ContextType,
type KeyringPair,
contextAwareThresholdDefault,
deviceContextTypes,
frictionlessImageThresholdDefault,
frictionlessTypesDefault,
imageMaxRoundsDefault,
Expand Down Expand Up @@ -145,12 +145,12 @@ export default (
disallowWebView: false,
contextAware: {
enabled: false,
contexts: {
[ContextType.Default]: {
type: ContextType.Default,
threshold: contextAwareThresholdDefault,
},
},
contexts: Object.fromEntries(
deviceContextTypes.map((type) => [
type,
{ type, threshold: contextAwareThresholdDefault },
]),
),
},
verifiedTimeout: 60000,
solutionTimeout: 60000,
Expand Down
66 changes: 43 additions & 23 deletions packages/provider/src/api/captcha/contextAwareValidation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,22 +13,57 @@
// limitations under the License.

import {
ContextType,
type ContextType,
type IUserSettings,
contextAwareThresholdDefault,
contextTypeFromSession,
expandContexts,
} from "@prosopo/types";

/**
* Determines the context type based on the webView flag
* Determines the context a request belongs to — its device family crossed
* with whether it is running in a webview.
*
* `userAgent` is the raw header value, not the hashed one carried on the
* decrypted payload. The entropy sweep classifies stored sessions from the
* same header through the same function, so the two sides agree on which
* bucket a session lands in.
*
* @param userAgent - Raw `user-agent` request header
* @param webView - Whether the request is from a WebView
* @returns The context type (Webview or Default)
*/
export function determineContextType(webView: boolean): ContextType {
return webView ? ContextType.Webview : ContextType.Default;
export function determineContextType(
userAgent: string | undefined,
webView: boolean,
): ContextType {
return contextTypeFromSession(userAgent, webView);
}

/**
* Gets the threshold for a specific context type from client settings
* Whether the client has this context configured.
*
* A context the customer has not enabled is not validated at all — the
* request passes through this stage untouched. That is a change from the
* pre-device-type behaviour, where configuring a single context validated
* *every* request against it: with six contexts, applying a tablet baseline
* to desktop traffic would reject real users wholesale.
*/
export function isContextConfigured(
settings: IUserSettings,
contextType: ContextType,
): boolean {
return (
expandContexts(settings.contextAware?.contexts)[contextType] !== undefined
);
}

/**
* Gets the threshold for a specific context type from client settings.
*
* Falls back to the global default when the context is unconfigured, so a
* caller that skipped `isContextConfigured` still gets a sane number rather
* than NaN.
*
* @param settings - Client settings
* @param contextType - The context type to get the threshold for
* @returns The threshold for the context type, or the global threshold if not configured
Expand All @@ -37,21 +72,6 @@ export function getContextThreshold(
settings: IUserSettings,
contextType: ContextType,
): number {
const contextAware = settings.contextAware;
if (contextAware === undefined) {
return contextAwareThresholdDefault;
}

const contexts = contextAware.contexts;
let contextConfig: { type: ContextType; threshold: number } | undefined;

if (contexts !== undefined) {
contextConfig = (
contexts as Partial<
Record<ContextType, { type: ContextType; threshold: number }>
>
)[contextType];
}

return contextConfig?.threshold ?? contextAwareThresholdDefault;
const contexts = expandContexts(settings.contextAware?.contexts);
return contexts[contextType]?.threshold ?? contextAwareThresholdDefault;
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
import { ProsopoApiError } from "@prosopo/common";
import {
CaptchaType,
ContextType,
type IPInfoResponse,
type RequestHeaders,
type ScoreComponents,
Expand All @@ -36,6 +35,7 @@ import { recordFrictionlessDecision } from "../../metrics.js";
import {
determineContextType,
getContextThreshold,
isContextConfigured,
} from "../contextAwareValidation.js";
import {
DECRYPTION_FAILED_IMAGE_ROUNDS,
Expand Down Expand Up @@ -507,20 +507,19 @@ const runContextAwareValidation = async (

if (!clientRecord.settings.contextAware?.enabled) return null;

const contexts = clientRecord.settings.contextAware?.contexts || {};
const hasDefault = contexts[ContextType.Default] !== undefined;
const hasWebview = contexts[ContextType.Webview] !== undefined;

let contextType: ContextType | undefined;
if (hasDefault && hasWebview) {
contextType = determineContextType(input.webView);
} else if (hasDefault) {
contextType = ContextType.Default;
} else if (hasWebview) {
contextType = ContextType.Webview;
}
// The request's own context: device family x webview. Classified from the
// raw header UA, which is what the off-provider entropy sweep reads off
// stored sessions — the two must agree or we look up a baseline nobody
// wrote.
const contextType = determineContextType(
req.headers["user-agent"],
input.webView,
);

if (!contextType) return null;
// Only validate contexts the customer has actually configured. Traffic
// from an unconfigured device family passes through: with six contexts,
// borrowing another family's baseline would reject real users.
if (!isContextConfigured(clientRecord.settings, contextType)) return null;

const clientEntropy = await tasks.frictionlessManager.getClientContextEntropy(
clientRecord.account,
Expand Down
Loading
Loading