Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

## 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`.

## 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
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
7 changes: 7 additions & 0 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 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

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.

Nit: em-dash in the comment; repo convention is zero em-dashes in committed code (comma or semicolon instead). Full ruleset: https://github.com/EdgeApp/edge-dev-agents/blob/main/.cursor/skills/no-slop/SKILL.md

* 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.

}
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