feat: fingerprinting for signup checks (#3254)

This commit is contained in:
Daniel Salazar
2026-06-12 00:59:21 -07:00
committed by GitHub
parent 13a36b6199
commit d3a2934d9b
8 changed files with 471 additions and 14 deletions
+7 -12
View File
@@ -1513,18 +1513,6 @@
"sisteransi": "^1.0.5"
}
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/@cloudflare/kv-asset-handler": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@cloudflare/kv-asset-handler/-/kv-asset-handler-0.3.4.tgz",
@@ -6652,6 +6640,12 @@
"eslint": "^9.0.0 || ^10.0.0"
}
},
"node_modules/@thumbmarkjs/thumbmarkjs": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/@thumbmarkjs/thumbmarkjs/-/thumbmarkjs-1.9.1.tgz",
"integrity": "sha512-Y8dhwoi53uS+dNUy9KGWgLftjY/zZVmenv0+cOZ00z1GKMagAdPmbG8lDMcrwMvYngEPq+7Vr9TQeuy3tmDrjA==",
"license": "MIT"
},
"node_modules/@tokenizer/inflate": {
"version": "0.4.1",
"license": "MIT",
@@ -18561,6 +18555,7 @@
"src/*"
],
"dependencies": {
"@thumbmarkjs/thumbmarkjs": "1.9.1",
"file-type": "21.3.3",
"json-colorizer": "^3.0.1",
"music-metadata": "11.12.3",
+6
View File
@@ -131,6 +131,9 @@ export type EventMap = {
data?: unknown;
abuse?: unknown;
trail?: Array<string>;
/** Device signals forwarded verbatim from the signup request body. */
fingerprint?: string | null;
dfp_telemetry_id?: string | null;
[key: string]: unknown;
};
'puter.signup.success': {
@@ -139,6 +142,9 @@ export type EventMap = {
email: string;
username: string;
ip?: string | null;
fingerprint?: string | null;
/** True when the created account is a temp user (no email/password). */
is_temp?: boolean;
[key: string]: unknown;
};
'email.validate': {
@@ -523,6 +523,247 @@ describe('AuthController.handleSignup', () => {
});
});
// -- Signup device signals (fingerprint + dfp_telemetry_id) --
describe('AuthController.handleSignup device signals', () => {
const uniq = () => Math.random().toString(36).slice(2, 10);
const captureValidateEvents = async (
fn: () => Promise<void>,
): Promise<Array<Record<string, unknown>>> => {
const seen: Array<Record<string, unknown>> = [];
await withSignupValidateOverride((event) => {
seen.push(event as unknown as Record<string, unknown>);
}, fn);
return seen;
};
const successEventsFor = (baseline: number, username: string) =>
heardSignupSuccess
.slice(baseline)
.filter(
(evt) => (evt as { username?: string }).username === username,
);
it('forwards fingerprint and dfp_telemetry_id verbatim to validate and success events', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const fingerprint = 'Fp_abc.123-XYZ';
const dfpTelemetryId = 'tel_id-456';
const seen = await captureValidateEvents(async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint,
dfp_telemetry_id: dfpTelemetryId,
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBe(fingerprint);
expect(seen[0].dfp_telemetry_id).toBe(dfpTelemetryId);
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].fingerprint).toBe(fingerprint);
expect(successes[0].is_temp).toBe(false);
});
it('accepts boundary-length values (128-char fingerprint, 64-char dfp_telemetry_id)', async () => {
const username = `fp_${uniq()}`;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 'f'.repeat(128),
dfp_telemetry_id: 'd'.repeat(64),
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
it('defaults both fields to null on the validate event when absent', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const seen = await captureValidateEvents(async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
}),
res,
);
// Signup completes exactly as before when the fields are absent.
expect(isCompleteLoginResponse(res.body)).toBe(true);
expect(res.cookies['puter_auth_token']).toBeDefined();
});
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
expect(seen[0].dfp_telemetry_id).toBeNull();
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].fingerprint).toBeNull();
expect(successes[0].is_temp).toBe(false);
});
it('treats empty-string fingerprint and dfp_telemetry_id as absent', async () => {
const username = `fp_${uniq()}`;
const seen = await captureValidateEvents(async () => {
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: '',
dfp_telemetry_id: '',
}),
res,
);
// An empty signal is "not collected", never a 400.
expect(isCompleteLoginResponse(res.body)).toBe(true);
});
expect(seen).toHaveLength(1);
expect(seen[0].fingerprint).toBeNull();
expect(seen[0].dfp_telemetry_id).toBeNull();
});
it('rejects a non-string fingerprint with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 12345,
}),
makeRes(),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
});
expect(heardSignupSuccess.length).toBe(baseline);
// No user row was created either.
expect(await server.stores.user.getByUsername(username)).toBeFalsy();
});
it('rejects a fingerprint longer than 128 characters with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
fingerprint: 'f'.repeat(129),
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(heardSignupSuccess.length).toBe(baseline);
});
it('rejects a dfp_telemetry_id longer than 64 characters with 400 and fires no success event', async () => {
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
await expect(
controller.handleSignup(
makeReq({
username,
email: `${username}@test.local`,
password: 'correct-horse-battery',
dfp_telemetry_id: 'd'.repeat(65),
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(heardSignupSuccess.length).toBe(baseline);
});
it('rejects a non-string dfp_telemetry_id with 400', async () => {
await expect(
controller.handleSignup(
makeReq({
username: `fp_${uniq()}`,
email: `${uniq()}@test.local`,
password: 'correct-horse-battery',
dfp_telemetry_id: { nested: true },
}),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('temp-user signup reports is_temp true and carries the fingerprint on the success event', async () => {
const baseline = heardSignupSuccess.length;
const res = makeRes();
await controller.handleSignup(
makeReq({ is_temp: true, fingerprint: 'temp-device-fp' }),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const username = (res.body as { user: { username: string } }).user
.username;
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].is_temp).toBe(true);
expect(successes[0].fingerprint).toBe('temp-device-fp');
});
it('pseudo-user claim reports is_temp false on the success event', async () => {
// Seed an unconfirmed placeholder row (email set, password null) —
// signing up with the same email claims it instead of inserting.
const placeholder = `ph_${uniq()}`;
const email = `${placeholder}@test.local`;
await server.stores.user.create({
username: placeholder,
uuid: uuidv4(),
password: null,
email,
});
const username = `fp_${uniq()}`;
const baseline = heardSignupSuccess.length;
const res = makeRes();
await controller.handleSignup(
makeReq({
username,
email,
password: 'correct-horse-battery',
fingerprint: 'claim-device-fp',
}),
res,
);
expect(isCompleteLoginResponse(res.body)).toBe(true);
const successes = successEventsFor(baseline, username);
expect(successes).toHaveLength(1);
expect(successes[0].is_temp).toBe(false);
expect(successes[0].fingerprint).toBe('claim-device-fp');
});
});
// ── Login flow ──────────────────────────────────────────────────────
describe('AuthController.handleLogin', () => {
@@ -57,6 +57,8 @@ import { PuterController } from '../types.js';
const USERNAME_REGEX = /^\w{1,}$/;
const USERNAME_MAX_LENGTH = 45;
const FINGERPRINT_MAX_LENGTH = 128;
const DFP_TELEMETRY_ID_MAX_LENGTH = 64;
const RESERVED_USERNAMES = new Set([
'admin',
'administrator',
@@ -375,6 +377,43 @@ export class AuthController extends PuterController {
return;
}
// Optional device signals (browser fingerprint hash, DFP telemetry
// id). Core only enforces shape and forwards the values verbatim —
// signup-abuse policy built on them lives in extensions. Checked
// before the reauth short-circuit so a malformed value is rejected
// on every /signup path.
if (body.fingerprint !== undefined && body.fingerprint !== null) {
if (typeof body.fingerprint !== 'string')
throw new HttpError(400, 'fingerprint must be a string.', {
legacyCode: 'bad_request',
});
if (body.fingerprint.length > FINGERPRINT_MAX_LENGTH)
throw new HttpError(
400,
`fingerprint cannot be longer than ${FINGERPRINT_MAX_LENGTH} characters.`,
{ legacyCode: 'bad_request' },
);
}
if (
body.dfp_telemetry_id !== undefined &&
body.dfp_telemetry_id !== null
) {
if (typeof body.dfp_telemetry_id !== 'string')
throw new HttpError(400, 'dfp_telemetry_id must be a string.', {
legacyCode: 'bad_request',
});
if (body.dfp_telemetry_id.length > DFP_TELEMETRY_ID_MAX_LENGTH)
throw new HttpError(
400,
`dfp_telemetry_id cannot be longer than ${DFP_TELEMETRY_ID_MAX_LENGTH} characters.`,
{ legacyCode: 'bad_request' },
);
}
// Empty strings are treated as absent — a signal that wasn't
// collected, not a malformed request.
const fingerprint: string | null = body.fingerprint || null;
const dfp_telemetry_id: string | null = body.dfp_telemetry_id || null;
// Temp-user reauth short-circuit: when an existing temp user is
// forced through the reauth flow, the GUI re-submits /signup with
// is_temp=true plus the server-signed reauth_token from the 401.
@@ -557,6 +596,8 @@ export class AuthController extends PuterController {
message: null,
code: null,
user_agent: req?.headers?.['user-agent'] ?? null,
fingerprint,
dfp_telemetry_id,
// Populated by the abuse extension's v2 harness; persisted to the
// user row below so the signup-time reputation is referable later.
reputation: null as number | null,
@@ -678,6 +719,8 @@ export class AuthController extends PuterController {
ip_fwd: proxyIpChain,
user_agent: req.headers?.['user-agent'],
origin: req.headers?.origin,
fingerprint,
dfp_telemetry_id,
},
signup_ip: clientIp,
signup_ip_forwarded: proxyIpChain,
@@ -757,6 +800,11 @@ export class AuthController extends PuterController {
user_uuid: user!.uuid,
email: user!.email,
username: user!.username,
fingerprint,
// Reflects the row that was actually created/claimed —
// a pseudo-user claim ends up with credentials, so it
// reports false here. Same signal completeLogin uses.
is_temp: user!.password === null && user!.email === null,
ip:
(req?.headers?.['x-forwarded-for'] as
| string
+1
View File
@@ -45,6 +45,7 @@
]
},
"dependencies": {
"@thumbmarkjs/thumbmarkjs": "1.9.1",
"file-type": "21.3.3",
"json-colorizer": "^3.0.1",
"music-metadata": "11.12.3",
+19 -1
View File
@@ -269,7 +269,7 @@ function UIWindowSignup (options) {
}
});
$(el_window).find('.signup-btn').on('click', function (e) {
$(el_window).find('.signup-btn').on('click', async function (e) {
// Clear previous error states
$(el_window).find('.signup-error-msg').hide();
@@ -351,6 +351,18 @@ function UIWindowSignup (options) {
headers = window.custom_headers;
}
// Device signals for abuse prevention; omitted when unavailable
let fingerprint = null;
let dfpTelemetryId = null;
try {
[fingerprint, dfpTelemetryId] = await Promise.all([
window.getDeviceFingerprint?.(),
window.getDfpTelemetryId?.(),
]);
} catch (_) {
// signup must never block or fail because of device signals
}
// Include captcha in request only if required
const requestData = {
username: username,
@@ -361,6 +373,12 @@ function UIWindowSignup (options) {
p102xyzname: p102xyzname,
'cf-turnstile-response': turnstileToken,
};
if ( fingerprint ) {
requestData.fingerprint = fingerprint;
}
if ( dfpTelemetryId ) {
requestData.dfp_telemetry_id = dfpTelemetryId;
}
$.ajax({
url: `${window.gui_origin }/signup`,
+126
View File
@@ -0,0 +1,126 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Thumbmark } from '@thumbmarkjs/thumbmarkjs';
const FINGERPRINT_TIMEOUT = 1500;
const DFP_TELEMETRY_TIMEOUT = 2500;
// Server-side caps for the matching /signup fields; values that would be
// rejected there are dropped client-side so signup can never 400 over them.
const FINGERPRINT_MAX_LENGTH = 128;
const DFP_TELEMETRY_ID_MAX_LENGTH = 64;
// Resolves null on rejection or timeout so signup flows can await these
// signals unconditionally without ever blocking or failing on them.
const settleWithin = (promise, ms) => {
return new Promise((resolve) => {
const timer = setTimeout(() => resolve(null), ms);
promise.then(
(value) => { clearTimeout(timer); resolve(value ?? null); },
() => { clearTimeout(timer); resolve(null); },
);
});
};
const asPlausible = (value, maxLength) => {
return typeof value === 'string' && value.length > 0 && value.length <= maxLength
? value
: null;
};
let fingerprint_promise = null;
const computeFingerprint = () => {
if ( ! fingerprint_promise ) {
// logging:false keeps the library fully offline; it would otherwise
// send a small sample of fingerprints to thumbmarkjs.com.
fingerprint_promise = new Thumbmark({ logging: false, timeout: FINGERPRINT_TIMEOUT })
.get()
.then(result => result?.thumbmark || null);
fingerprint_promise.catch(error => {
// Don't cache the failure — a later attempt may succeed.
fingerprint_promise = null;
console.debug('device fingerprint unavailable:', error);
});
}
return fingerprint_promise;
};
let stytch_script_promise = null;
const loadStytchScript = () => {
if ( ! stytch_script_promise ) {
stytch_script_promise = window.loadScript('https://elements.stytch.com/telemetry.js');
stytch_script_promise.catch(error => {
// Don't cache the failure — a transient load error would
// otherwise disable DFP for the rest of the session.
stytch_script_promise = null;
console.debug('Stytch telemetry script unavailable:', error);
});
}
return stytch_script_promise;
};
// Telemetry ids are short-lived, so unlike the fingerprint this is fetched
// fresh on every call; only the script load itself is reused.
const fetchDfpTelemetryId = async () => {
await loadStytchScript();
if ( typeof window.GetTelemetryID !== 'function' ) {
return null;
}
const telemetry_id = await window.GetTelemetryID({
publicToken: window.gui_params.stytchPublicToken,
});
return telemetry_id || null;
};
/**
* Installs window.getDeviceFingerprint() and window.getDfpTelemetryId(). Both
* getters resolve to string|null and never reject. Collection is lazy: no
* probing happens and no third-party script is loaded until a signup flow
* actually asks. The fingerprint needs no credentials so it's on by default
* (gui_params.thumbmarkEnabled = false is the kill switch); the Stytch
* telemetry id requires a public token and is collected only when one is
* configured.
*/
const init_device_signals = () => {
window.getDeviceFingerprint = () => {
try {
if ( window.gui_params?.thumbmarkEnabled === false ) {
return Promise.resolve(null);
}
return settleWithin(computeFingerprint(), FINGERPRINT_TIMEOUT)
.then(value => asPlausible(value, FINGERPRINT_MAX_LENGTH));
} catch (e) {
return Promise.resolve(null);
}
};
window.getDfpTelemetryId = () => {
try {
if ( ! window.gui_params?.stytchPublicToken ) {
return Promise.resolve(null);
}
return settleWithin(fetchDfpTelemetryId(), DFP_TELEMETRY_TIMEOUT)
.then(value => asPlausible(value, DFP_TELEMETRY_ID_MAX_LENGTH));
} catch (e) {
return Promise.resolve(null);
}
};
};
export default init_device_signals;
+23 -1
View File
@@ -36,6 +36,7 @@ import UIWindowSignup from './UI/UIWindowSignup.js';
import UIWindowRecoverPassword from './UI/UIWindowRecoverPassword.js';
import { PROCESS_RUNNING } from './definitions.js';
import create_access_token from './helpers/create_access_token.js';
import init_device_signals from './helpers/device_signals.js';
import item_icon from './helpers/item_icon.js';
import update_last_touch_coordinates from './helpers/update_last_touch_coordinates.js';
import update_mouse_position from './helpers/update_mouse_position.js';
@@ -302,6 +303,11 @@ window.initgui = async function (options) {
const url_paths = window.location.pathname.split('/').filter(element => element);
window.url_paths = url_paths;
// Install device signal helpers; collection is lazy. The fingerprint is
// on by default (gui_params.thumbmarkEnabled = false kills it); the Stytch
// telemetry id needs gui_params.stytchPublicToken.
init_device_signals();
let picked_a_user_for_sdk_login = false;
// update SDK if auth_token is different from the one in the SDK
@@ -1238,7 +1244,7 @@ window.initgui = async function (options) {
}
// Function to create temp user after captcha completion
const createTempUser = (turnstileToken) => {
const createTempUser = async (turnstileToken) => {
// if this is a popup, show a spinner
let spinner_init_ts = Date.now();
const requestData = {
@@ -1251,6 +1257,22 @@ window.initgui = async function (options) {
requestData['cf-turnstile-response'] = turnstileToken;
}
// Device signals for abuse prevention; omitted when unavailable
try {
const [fingerprint, dfpTelemetryId] = await Promise.all([
window.getDeviceFingerprint?.(),
window.getDfpTelemetryId?.(),
]);
if ( fingerprint ) {
requestData.fingerprint = fingerprint;
}
if ( dfpTelemetryId ) {
requestData.dfp_telemetry_id = dfpTelemetryId;
}
} catch (e) {
// signup must never block or fail because of device signals
}
$.ajax({
url: `${window.gui_origin }/signup`,
type: 'POST',