From 552ba2184696f0798183b31f635c44407ba63b8c Mon Sep 17 00:00:00 2001 From: Paul V Puey Date: Sat, 8 Aug 2026 13:49:01 -0700 Subject: [PATCH] Send attestation tokens on login-server requests Add setAttestationToken and attach x-attestation-token so attested clients can skip CAPTCHA challenges from the login server. Validate private LAN hosts via real IPv4 octets (not DNS prefix matches), update the rejection message, and treat empty-string attestation tokens as cleared. --- CHANGELOG.md | 4 ++ src/core/actions.ts | 5 ++ src/core/context/context-api.ts | 4 ++ src/core/fake/fake-world.ts | 23 ++++++--- src/core/login/login-fetch.ts | 7 ++- src/core/login/login-reducer.ts | 8 ++++ src/types/types.ts | 11 ++++- src/util/validateServer.ts | 36 ++++++++++++-- test/core/login/attestation-header.test.ts | 56 ++++++++++++++++++++++ test/util/validateServer.test.ts | 27 +++++++++-- 10 files changed, 163 insertions(+), 18 deletions(-) create mode 100644 test/core/login/attestation-header.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index b15bfa580..f16d297df 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- added: `EdgeContext.setAttestationToken` to attach an `x-attestation-token` header on login-server requests. +- changed: `validateServer` accepts private LAN IPv4 addresses (RFC1918 + 127/8) for `http`/`ws` server overrides only; `https`/`wss` still require localhost or `*.edge(test)?.app`. +- changed: Fake-world `allowNetworkAccess` fakes only login/info/sync `*.edge.app` hosts; other traffic (including change servers and private LAN) uses real `io.fetch`. + ## 2.47.1 (2026-07-17) - fixed: Revert `@nymproject/mix-fetch` to v1 (1.4.4), restoring the pinned gateway and network requester. The v2 stack shipped in 2.47.0 fails to complete small HTTPS JSON-RPC requests through most exit nodes and its exit-node auto-discovery rarely converges, which left wallets with NYM privacy enabled unable to sync or send. diff --git a/src/core/actions.ts b/src/core/actions.ts index dfb685e44..38f36c23f 100644 --- a/src/core/actions.ts +++ b/src/core/actions.ts @@ -436,6 +436,11 @@ export type RootAction = timestamp: number } } + | { + // Sets the device attestation token sent to the login server. + type: 'SET_ATTESTATION_TOKEN' + payload: string | undefined + } | { // Fires when a user logs out. type: 'LOGOUT' diff --git a/src/core/context/context-api.ts b/src/core/context/context-api.ts index 2fb62ccdb..8c7a571e6 100644 --- a/src/core/context/context-api.ts +++ b/src/core/context/context-api.ts @@ -512,6 +512,10 @@ export function makeContextApi(ai: ApiInput): EdgeContext { async changeLogSettings(settings: Partial): Promise { const newSettings = { ...ai.props.state.logSettings, ...settings } ai.props.dispatch({ type: 'CHANGE_LOG_SETTINGS', payload: newSettings }) + }, + + async setAttestationToken(token: string | undefined): Promise { + ai.props.dispatch({ type: 'SET_ATTESTATION_TOKEN', payload: token }) } } bridgifyObject(out) diff --git a/src/core/fake/fake-world.ts b/src/core/fake/fake-world.ts index 808d48180..0f3eafa56 100644 --- a/src/core/fake/fake-world.ts +++ b/src/core/fake/fake-world.ts @@ -22,7 +22,6 @@ import { EdgeIo } from '../../types/types' import { base58 } from '../../util/encoding' -import { validateServer } from '../../util/validateServer' import { LogBackend } from '../log/log' import { applyLoginPayload } from '../login/login' import { wasLoginStash } from '../login/login-stash' @@ -32,6 +31,20 @@ import { makeRepoPaths, saveChanges } from '../storage/repo' import { FakeDb } from './fake-db' import { makeFakeServer } from './fake-server' +/** + * Account infrastructure hosts that stay on the in-memory fake server when + * `allowNetworkAccess` is enabled. Change servers and everything else use + * real `io.fetch`. + */ +function isFakeAccountInfrastructure(uri: string): boolean { + try { + const { hostname } = new URL(uri) + return /^(login|info|sync)[a-z0-9-]*\.edge(test)?\.app$/i.test(hostname) + } catch { + return false + } +} + async function saveLogin(io: EdgeIo, user: EdgeFakeUser): Promise { const { lastLogin, server } = user const loginId = base64.parse(user.loginId) @@ -114,12 +127,10 @@ export function makeFakeWorld( const fetch: EdgeFetchFunction = !allowNetworkAccess ? fakeFetch : (uri, opts) => { - try { - validateServer(uri) // Throws for non-Edge servers. - } catch (error: unknown) { - return io.fetch(uri, opts) + if (isFakeAccountInfrastructure(uri)) { + return fakeFetch(uri, opts) } - return fakeFetch(uri, opts) + return io.fetch(uri, opts) } const fakeIo = { diff --git a/src/core/login/login-fetch.ts b/src/core/login/login-fetch.ts index fa201c981..d0fe5f720 100644 --- a/src/core/login/login-fetch.ts +++ b/src/core/login/login-fetch.ts @@ -117,7 +117,7 @@ export function loginFetchInner( body?: LoginRequestBody ): Promise { const { state, io, log } = ai.props - const { apiKey, apiSecret } = state.login + const { apiKey, apiSecret, attestationToken } = state.login const bodyText = method === 'GET' || body == null @@ -138,7 +138,10 @@ export function loginFetchInner( headers: { 'content-type': 'application/json', accept: 'application/json', - authorization + authorization, + ...(attestationToken != null + ? { 'x-attestation-token': attestationToken } + : {}) }, corsBypass: 'never' } diff --git a/src/core/login/login-reducer.ts b/src/core/login/login-reducer.ts index b3ea0ec96..0cd9f947a 100644 --- a/src/core/login/login-reducer.ts +++ b/src/core/login/login-reducer.ts @@ -20,6 +20,7 @@ export interface DeviceInfo { export interface LoginState { readonly apiKey: string readonly apiSecret: Uint8Array | null + readonly attestationToken: string | null readonly contextAppId: string readonly deviceInfo: DeviceInfo readonly loginServers: string[] @@ -37,6 +38,13 @@ export const login = buildReducer({ return action.type === 'INIT' ? action.payload.apiSecret ?? null : state }, + attestationToken(state = null, action): string | null { + if (action.type !== 'SET_ATTESTATION_TOKEN') return state + const token = action.payload + // Treat empty string like clear so we never send x-attestation-token: ''. + return token == null || token === '' ? null : token + }, + contextAppId(state = '', action): string { return action.type === 'INIT' ? action.payload.appId : state }, diff --git a/src/types/types.ts b/src/types/types.ts index d7fe8fec5..49c9556bd 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -2172,6 +2172,13 @@ export interface EdgeContext { readonly changeLogSettings: ( settings: Partial ) => Promise + + /** + * Supplies the latest device attestation token for login-server requests. + * Pass `undefined` or `''` to clear the header. Only subsequent login-server + * requests pick up the new value. + */ + readonly setAttestationToken: (token: string | undefined) => Promise } // --------------------------------------------------------------------- @@ -2194,8 +2201,8 @@ export interface EdgeFakeContextOptions { logSettings?: Partial plugins?: EdgeCorePluginsInit - // Allows core plugins to access the real network except for the - // login and sync servers, which remain emulated: + // Allows core plugins to access the real network except for login, info, + // and sync servers, which remain emulated: allowNetworkAccess?: boolean // Fake device options: diff --git a/src/util/validateServer.ts b/src/util/validateServer.ts index 94cf96345..e8adb174c 100644 --- a/src/util/validateServer.ts +++ b/src/util/validateServer.ts @@ -1,11 +1,13 @@ /** - * We only accept *.edge.app or localhost as valid domain names. + * We only accept *.edge.app, localhost, or (for http/ws only) private LAN IPv4. + * https/wss still require localhost or *.edge(test)?.app; private IPs are not + * accepted on secure schemes. */ export function validateServer(server: string): void { const url = new URL(server) if (url.protocol === 'http:' || url.protocol === 'ws:') { - if (url.hostname === 'localhost') return + if (isPrivateHost(url.hostname)) return } if (url.protocol === 'https:' || url.protocol === 'wss:') { if (url.hostname === 'localhost') return @@ -13,6 +15,34 @@ export function validateServer(server: string): void { } throw new Error( - `Only *.edge.app or localhost are valid login domain names, not ${url.hostname}` + `Only *.edge.app, localhost, or private LAN addresses (http/ws) are valid login domain names, not ${url.hostname}` ) } + +function isPrivateHost(hostname: string): boolean { + if (hostname === 'localhost') return true + const octets = parseIpv4(hostname) + if (octets == null) return false + const [a, b] = octets + if (a === 127) return true + if (a === 10) return true + if (a === 192 && b === 168) return true + if (a === 172 && b >= 16 && b <= 31) return true + return false +} + +function parseIpv4(hostname: string): [number, number, number, number] | null { + const parts = hostname.split('.') + if (parts.length !== 4) return null + const octets: number[] = [] + for (const part of parts) { + if (!/^\d{1,3}$/.test(part)) return null + const n = Number(part) + if (!Number.isInteger(n) || n < 0 || n > 255) return null + // Reject leading zeros like 010.0.0.1 which are not canonical dotted-quad + // when they reach this helper (URL parsing may already rewrite some forms). + if (part.length > 1 && part.startsWith('0')) return null + octets.push(n) + } + return octets as [number, number, number, number] +} diff --git a/test/core/login/attestation-header.test.ts b/test/core/login/attestation-header.test.ts new file mode 100644 index 000000000..b62fe7c9d --- /dev/null +++ b/test/core/login/attestation-header.test.ts @@ -0,0 +1,56 @@ +import { expect } from 'chai' +import { describe, it } from 'mocha' + +import { getInternalStuff } from '../../../src/core/context/internal-api' +import { makeFakeWorld } from '../../../src/core/core' +import { makeFakeIo } from '../../../src/index' +import { + EdgeFetchFunction, + EdgeFetchOptions, + EdgeFetchResponse +} from '../../../src/types/types' +import { fakeUser } from '../../fake/fake-user' + +const contextOptions = { apiKey: '', appId: '' } +const quiet = { onLog() {} } + +describe('attestation header', function () { + it('attaches and clears x-attestation-token on login-server requests', async function () { + // Use unbridged makeFakeWorld so we can spy on the context io.fetch + // that loginFetchInner calls (makeFakeEdgeWorld's yaob bridge hides `_ai`). + const world = makeFakeWorld({ io: makeFakeIo(), nativeIo: {} }, quiet, [ + fakeUser + ]) + const context = await world.makeEdgeContext(contextOptions) + + const stuff = getInternalStuff(context) as any + const io = stuff._ai.props.io + const originalFetch: EdgeFetchFunction = io.fetch.bind(io) + let lastHeaders: EdgeFetchOptions['headers'] + io.fetch = async ( + uri: string, + opts?: EdgeFetchOptions + ): Promise => { + if (uri.includes('/api/')) { + lastHeaders = opts?.headers + } + return await originalFetch(uri, opts) + } + + await context.setAttestationToken('jwt') + await context.usernameAvailable('unknown user') + expect(lastHeaders?.['x-attestation-token']).equals('jwt') + + await context.setAttestationToken(undefined) + await context.usernameAvailable('unknown user') + expect(lastHeaders).to.not.have.property('x-attestation-token') + + await context.setAttestationToken('jwt-again') + await context.usernameAvailable('unknown user') + expect(lastHeaders?.['x-attestation-token']).equals('jwt-again') + + await context.setAttestationToken('') + await context.usernameAvailable('unknown user') + expect(lastHeaders).to.not.have.property('x-attestation-token') + }) +}) diff --git a/test/util/validateServer.test.ts b/test/util/validateServer.test.ts index 90fdca15f..cd5594e97 100644 --- a/test/util/validateServer.test.ts +++ b/test/util/validateServer.test.ts @@ -3,6 +3,9 @@ import { describe, it } from 'mocha' import { validateServer } from '../../src/util/validateServer' +const rejectMessage = + 'Only *.edge.app, localhost, or private LAN addresses (http/ws) are valid login domain names' + describe('validateServer', function () { it('accepts valid login server overrides', function () { for (const server of [ @@ -15,7 +18,12 @@ describe('validateServer', function () { 'http://localhost', 'http://localhost/app', 'https://localhost/app', - 'http://localhost:8080/app' + 'http://localhost:8080/app', + 'http://127.0.0.1:8008', + 'http://192.168.1.50:3123', + 'http://10.0.0.5', + 'ws://172.16.0.1', + 'http://172.31.255.255' ]) { validateServer(server) } @@ -28,11 +36,20 @@ describe('validateServer', function () { 'https://edge.app:fun@hacker.com/app', 'https://login.edgetes.app/app', 'http://login.edge.app/app', - 'ftp://login.edge.app' + 'ftp://login.edge.app', + 'http://172.32.0.1', + 'http://172.15.255.255', + 'http://8.8.8.8', + 'http://11.0.0.1', + 'https://192.168.1.50', + 'https://127.0.0.1', + 'wss://127.0.0.1', + // Prefix-only DNS names must not match the private-IP allowlist: + 'http://10.evil.com', + 'http://192.168.evil.com', + 'http://172.16.evil.com' ]) { - expect(() => validateServer(server)).to.throw( - 'Only *.edge.app or localhost are valid login domain names' - ) + expect(() => validateServer(server)).to.throw(rejectMessage) } }) })