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) } }) })