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
5 changes: 3 additions & 2 deletions docs/privacy-relayers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion lib/privacyRelayerStatus.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
28 changes: 28 additions & 0 deletions tests/privacyRelayerDirectory.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down Expand Up @@ -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();
Expand Down
18 changes: 15 additions & 3 deletions tests/privacyRelayerStatus.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 });
Expand Down
Loading