From d3a2934d9b7d9898627e17eba8882da90f9896ea Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Fri, 12 Jun 2026 00:59:21 -0700 Subject: [PATCH] feat: fingerprinting for signup checks (#3254) --- package-lock.json | 19 +- src/backend/clients/event/types.ts | 6 + .../controllers/auth/AuthController.test.ts | 241 ++++++++++++++++++ .../controllers/auth/AuthController.ts | 48 ++++ src/gui/package.json | 1 + src/gui/src/UI/UIWindowSignup.js | 20 +- src/gui/src/helpers/device_signals.js | 126 +++++++++ src/gui/src/initgui.js | 24 +- 8 files changed, 471 insertions(+), 14 deletions(-) create mode 100644 src/gui/src/helpers/device_signals.js diff --git a/package-lock.json b/package-lock.json index 0b00f59b1..4af7cda03 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/src/backend/clients/event/types.ts b/src/backend/clients/event/types.ts index 6bb7c251b..37902fc18 100644 --- a/src/backend/clients/event/types.ts +++ b/src/backend/clients/event/types.ts @@ -131,6 +131,9 @@ export type EventMap = { data?: unknown; abuse?: unknown; trail?: Array; + /** 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': { diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index 5f521b833..b89abce7c 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -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, + ): Promise>> => { + const seen: Array> = []; + await withSignupValidateOverride((event) => { + seen.push(event as unknown as Record); + }, 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', () => { diff --git a/src/backend/controllers/auth/AuthController.ts b/src/backend/controllers/auth/AuthController.ts index b3f253928..ec90f6fa4 100644 --- a/src/backend/controllers/auth/AuthController.ts +++ b/src/backend/controllers/auth/AuthController.ts @@ -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 diff --git a/src/gui/package.json b/src/gui/package.json index 1d47f9488..a4c3b44f7 100644 --- a/src/gui/package.json +++ b/src/gui/package.json @@ -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", diff --git a/src/gui/src/UI/UIWindowSignup.js b/src/gui/src/UI/UIWindowSignup.js index 6b390f832..392d09780 100644 --- a/src/gui/src/UI/UIWindowSignup.js +++ b/src/gui/src/UI/UIWindowSignup.js @@ -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`, diff --git a/src/gui/src/helpers/device_signals.js b/src/gui/src/helpers/device_signals.js new file mode 100644 index 000000000..80824b7c1 --- /dev/null +++ b/src/gui/src/helpers/device_signals.js @@ -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 . + */ + +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; diff --git a/src/gui/src/initgui.js b/src/gui/src/initgui.js index e02f7a05b..ef70a5832 100644 --- a/src/gui/src/initgui.js +++ b/src/gui/src/initgui.js @@ -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',