diff --git a/docs/privacy-relayers.md b/docs/privacy-relayers.md index 6306008..bc9b903 100644 --- a/docs/privacy-relayers.md +++ b/docs/privacy-relayers.md @@ -70,8 +70,9 @@ trust assumption; these reads are not a cryptographic Ethereum light client. ## Endpoint boundaries and response meaning -ENS records are untrusted. Only a root HTTPS URL on a DNS hostname with the -standard 443 port is accepted; credentials, IP literals, custom paths, queries +ENS records are untrusted. A bare DNS hostname is normalized to a root HTTPS URL; +root HTTPS URLs on DNS hostnames with the standard 443 port are also accepted. +Credentials, IP literals, custom paths, queries and fragments are rejected. Only fixed `/status` is requested. DNS must return one to eight public addresses, all of which pass IPv4/IPv6 special-range checks. An approved address is pinned into the actual TLS request, with the original diff --git a/lib/privacyRelayerStatus.js b/lib/privacyRelayerStatus.js index 445c0af..d25a332 100644 --- a/lib/privacyRelayerStatus.js +++ b/lib/privacyRelayerStatus.js @@ -21,7 +21,10 @@ function publicAddress(ip) { function endpoint(value) { if (typeof value !== 'string' || value.length > 2048 || /[\s\\]/.test(value)) throw new Error('privacy_relayer_endpoint_invalid'); let url; - try { url = new URL(value); } catch (_) { throw new Error('privacy_relayer_endpoint_invalid'); } + // Classic ENS records may contain a bare DNS hostname. Accept only that + // narrow form and apply the same HTTPS and hostname checks as an origin. + const normalized = /^[a-z0-9.-]+$/i.test(value) ? `https://${value}` : value; + try { url = new URL(normalized); } catch (_) { throw new Error('privacy_relayer_endpoint_invalid'); } if (url.protocol !== 'https:' || url.username || url.password || url.port || url.search || url.hash || url.pathname !== '/' || isIP(url.hostname) || url.hostname.startsWith('[') || url.hostname.length > 253 || !url.hostname.includes('.') || url.hostname.split('.').some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) throw new Error('privacy_relayer_endpoint_invalid'); diff --git a/tests/privacyRelayerDirectory.test.js b/tests/privacyRelayerDirectory.test.js index dfc07ec..bb5a549 100644 --- a/tests/privacyRelayerDirectory.test.js +++ b/tests/privacyRelayerDirectory.test.js @@ -3,9 +3,13 @@ const fs = require('node:fs/promises'); const os = require('node:os'); const path = require('node:path'); +const { EventEmitter } = require('node:events'); +const { PassThrough } = require('node:stream'); const { namehash } = require('ethers'); const { createPrivacyRelayerDirectory, createRelayerCheckpointStore, configuredPrivacyRelayerDirectory, SOURCE_ID, REFRESH_MS, JOB_MS, RANGE, MAX_CHUNKS } = require('../lib/privacyRelayerDirectory'); +const { createPrivacyRelayerStatus } = require('../lib/privacyRelayerStatus'); +const { POOLS } = require('../lib/privacyRegistry'); const { DEPLOYMENT_BLOCK, MAX_CANDIDATES } = require('../lib/privacyRelayerRegistry'); const owner = '0x1111111111111111111111111111111111111111'; const candidate = { ensName: 'relay.eth', ensHash: namehash('relay.eth'), registeredAddress: owner }; @@ -52,6 +56,30 @@ test('unused directory stays idle; first bulk read publishes only public anchore expect(JSON.stringify(s.disk)).not.toContain('endpoint'); expect(JSON.stringify(s.disk)).not.toContain('Fee'); await s.directory.close(); expect(s.timers.size).toBe(0); }); +test('registered bare ENS hostname yields a selectable HTTPS relayer through guarded status inspection', async () => { + const s = setup(); + s.rpc.resolve.mockResolvedValue({ ensName: candidate.ensName, ensHash: candidate.ensHash, owner, + registryBalance: '123', endpoint: 'relay.example' }); + const request = jest.fn((options, callback) => { + const req = new EventEmitter(); req.destroy = jest.fn(); + req.end = () => queueMicrotask(() => { + const res = new PassThrough(); res.statusCode = 200; res.headers = { 'content-type': 'application/json' }; + callback(res); + res.end(JSON.stringify({ netId: 1, rewardAccount: owner, + instances: { eth: { decimals: 18, instanceAddress: Object.fromEntries(POOLS.map((p) => [p.id.slice(4), p.address])) } }, + tornadoServiceFee: 0.3, health: { status: 'true' }, currentQueue: 0, version: '5.0.1' })); + }); + return req; + }); + const inspect = createPrivacyRelayerStatus({ lookup: async () => [{ address: '8.8.8.8', family: 4 }], request, now: () => 1700000000000 }); + s.inspect.mockImplementation(inspect); + const res = await ready(s); + expect(res.body.relayers[0]).toMatchObject({ endpoint: 'https://relay.example', health: 'reported-healthy', + rewardAccount: owner, supportedPools: POOLS.map((p) => p.id) }); + expect(request.mock.calls[0][0]).toMatchObject({ protocol: 'https:', hostname: 'relay.example', port: 443, path: '/status', rejectUnauthorized: true }); + expect(s.rpc.rewardRegistered).toHaveBeenCalledWith(owner, owner, s.header, expect.any(Object)); + await s.directory.close(); +}); test('deduplicates concurrent demand into one registry scan and one status request', async () => { const s = setup(); await Promise.all(Array.from({ length: 10 }, () => s.directory.read())); await s.directory.idle(); expect(s.rpc.head).toHaveBeenCalledTimes(1); expect(s.inspect).toHaveBeenCalledTimes(1); await s.directory.close(); diff --git a/tests/privacyRelayerStatus.test.js b/tests/privacyRelayerStatus.test.js index e525643..408efc4 100644 --- a/tests/privacyRelayerStatus.test.js +++ b/tests/privacyRelayerStatus.test.js @@ -28,19 +28,26 @@ describe('public relayer status transport', () => { test.each(['http://public.example', 'https://127.1', 'https://2130706433', 'https://[::1]', 'https://169.254.169.254', 'https://user:pass@public.example', 'https://public.example:8443', 'https://public.example/path', 'https://public.example?recipient=x', 'https://public.example#x', - 'https://localhost', 'https://public.example.', 'https://public.example\\@private.example'])('rejects non-fixed endpoint %s', (url) => { + 'https://localhost', 'https://public.example.', 'https://public.example\\@private.example', + 'localhost', '127.1', '2130706433', '169.254.169.254', 'public.example.', + 'public.example:443', 'public.example/path', 'public.example?x=1', 'public.example#x', + 'user@public.example', 'public.example\\@private.example', 'public%2eexample', + ' public.example', 'public.example\n', '-public.example', 'public..example'])('rejects non-fixed endpoint %s', (url) => { expect(() => endpoint(url)).toThrow(); }); + test.each(['public.example', 'PUBLIC.Example', 'https://public.example/'])('normalizes a fixed HTTPS origin from %s', (url) => { + expect(endpoint(url)).toBe('https://public.example'); + }); test.each(['0.1.2.3', '10.0.0.1', '100.64.0.1', '127.0.0.1', '169.254.169.254', '172.31.1.2', '192.168.1.1', '192.0.0.2', '198.18.0.1', '203.0.113.2', '224.0.0.1', '255.255.255.255', '::', '::1', 'fc00::1', 'fe80::1', '::ffff:8.8.8.8', '64:ff9b::a00:1', '2001:db8::1', '2002:0808:0808::1'])('blocks non-public address %s', (ip) => { expect(publicAddress(ip)).toBe(false); }); test.each(['8.8.8.8', '1.1.1.1', '2606:4700:4700::1111', '2001:4860:4860::8888'])('allows public address %s', (ip) => expect(publicAddress(ip)).toBe(true)); - test('pins checked DNS to TLS host and projects only advertised fields', async () => { + test.each(['public.example', 'https://public.example/'])('pins checked DNS to TLS host and projects only advertised fields for %s', async (input) => { const answers = [{ address: '8.8.8.8', family: 4 }]; const request = transport(); - const result = await createPrivacyRelayerStatus({ lookup: async () => answers, request, now: () => 123 })('https://public.example/'); + const result = await createPrivacyRelayerStatus({ lookup: async () => answers, request, now: () => 123 })(input); const options = request.mock.calls[0][0]; expect(options).toMatchObject({ hostname: 'public.example', servername: 'public.example', path: '/status', port: 443, rejectUnauthorized: true, agent: false }); answers[0].address = '127.0.0.1'; @@ -55,6 +62,11 @@ describe('public relayer status transport', () => { const inspect = createPrivacyRelayerStatus({ lookup: async () => [...await publicDns(), { address: '127.0.0.1', family: 4 }], request }); await expect(inspect('https://public.example')).rejects.toThrow('dns_rejected'); expect(request).not.toHaveBeenCalled(); }); + test('bare host rejects mixed private DNS and never sends a request', async () => { + const request = transport(); + const inspect = createPrivacyRelayerStatus({ lookup: async () => [{ address: '8.8.8.8', family: 4 }, { address: '127.0.0.1', family: 4 }], request }); + await expect(inspect('public.example')).rejects.toThrow('dns_rejected'); expect(request).not.toHaveBeenCalled(); + }); test('close/deadline guard runs again after DNS and before HTTPS', async () => { const request = transport(); let stopped = false; const inspect = createPrivacyRelayerStatus({ lookup: async () => { stopped = true; return publicDns(); }, request });