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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
5 changes: 5 additions & 0 deletions src/core/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
4 changes: 4 additions & 0 deletions src/core/context/context-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -512,6 +512,10 @@ export function makeContextApi(ai: ApiInput): EdgeContext {
async changeLogSettings(settings: Partial<EdgeLogSettings>): Promise<void> {
const newSettings = { ...ai.props.state.logSettings, ...settings }
ai.props.dispatch({ type: 'CHANGE_LOG_SETTINGS', payload: newSettings })
},

async setAttestationToken(token: string | undefined): Promise<void> {
ai.props.dispatch({ type: 'SET_ATTESTATION_TOKEN', payload: token })
}
}
bridgifyObject(out)
Expand Down
23 changes: 17 additions & 6 deletions src/core/fake/fake-world.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand All @@ -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<void> {
const { lastLogin, server } = user
const loginId = base64.parse(user.loginId)
Expand Down Expand Up @@ -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 = {
Expand Down
7 changes: 5 additions & 2 deletions src/core/login/login-fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ export function loginFetchInner(
body?: LoginRequestBody
): Promise<EdgeFetchResponse> {
const { state, io, log } = ai.props
const { apiKey, apiSecret } = state.login
const { apiKey, apiSecret, attestationToken } = state.login

const bodyText =
method === 'GET' || body == null
Expand All @@ -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'
}
Expand Down
8 changes: 8 additions & 0 deletions src/core/login/login-reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand All @@ -37,6 +38,13 @@ export const login = buildReducer<LoginState, RootAction, RootState>({
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
},
Expand Down
11 changes: 9 additions & 2 deletions src/types/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2172,6 +2172,13 @@ export interface EdgeContext {
readonly changeLogSettings: (
settings: Partial<EdgeLogSettings>
) => Promise<void>

/**
* 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<void>
}

// ---------------------------------------------------------------------
Expand All @@ -2194,8 +2201,8 @@ export interface EdgeFakeContextOptions {
logSettings?: Partial<EdgeLogSettings>
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:
Expand Down
36 changes: 33 additions & 3 deletions src/util/validateServer.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,48 @@
/**
* 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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effect on fake-world routing: makeFakeWorld with allowNetworkAccess: true routes fetches by "passes validateServer -> fakeFetch, throws -> real network" (src/core/fake/fake-world.ts). Private-IP URIs now pass, so a fake-world test pointed at a real LAN dev server (the exact use case this PR enables) silently gets answered by the in-memory fake server instead of the network. If that is not intended, the fake-world path may want to keep the old localhost-only check.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 552ba21, and in a better place than I suggested: fake-world no longer consults validateServer at all, so test routing stops tracking a production policy function. The explicit isFakeAccountInfrastructure allowlist is the right decoupling.

I checked the regex against the hosts this repo actually ships: login1/login2, info1/info2, sync-us1 through sync-us6, sync-eu (root.ts) and the fake server's own info-fake1, sync-fake1..3 all match.

Two intentional behavior changes worth naming, both consistent with the updated doc comment: under allowNetworkAccess, localhost and non-account *.edge.app hosts (the cors1..4.edge.app proxies, for instance) now reach the real network instead of the fake server.

}
if (url.protocol === 'https:' || url.protocol === 'wss:') {
if (url.hostname === 'localhost') return
if (/^([A-Za-z0-9_-]+\.)*edge(test)?\.app$/.test(url.hostname)) return
}

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]
}
56 changes: 56 additions & 0 deletions test/core/login/attestation-header.test.ts
Original file line number Diff line number Diff line change
@@ -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<EdgeFetchResponse> => {
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')
})
})
27 changes: 22 additions & 5 deletions test/util/validateServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 [
Expand All @@ -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)
}
Expand All @@ -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)
}
})
})
Loading