From 15dcf94bcf1651900ca6118df63b732b0139220d Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 01:23:31 +0000 Subject: [PATCH 1/2] fix(egress): refused requests log a hash of the requested host and path Egress audit lines carried destinationHost and pathHash only after a request passed policy validation, so a HOST_NOT_ALLOWED refusal could not be traced to a destination (seen on AX41 after the #16 deploy). The gateway now records destinationHostHash (hashLabel of the lowercased host) plus pathHash and queryPresent from the requested URL right after the grant is read, for HTTPS passthrough, package transport and typed fetch. The host itself is not logged for refusals, since sandbox code chooses it freely; an operator compares the hash with hashLabel of a candidate host. --- service/src/egress-gateway.test.ts | 31 +++++++++++++++++++++++++----- service/src/egress-gateway.ts | 27 ++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 5 deletions(-) diff --git a/service/src/egress-gateway.test.ts b/service/src/egress-gateway.test.ts index 34d03805..193e57b5 100644 --- a/service/src/egress-gateway.test.ts +++ b/service/src/egress-gateway.test.ts @@ -1886,15 +1886,36 @@ describe('egress gateway routes', () => { expect(opaque.status).toBe(404); expect(await opaque.text()).toBe('not found'); - const denied = await gatewayFetch('/https-passthrough', { - method: 'POST', - headers: { ...grantHeader(), 'Content-Type': 'application/json' }, - body, - }); + const rejections: Record[] = []; + const originalLoggerWarn = logger.warn; + logger.warn = ((message: string, fields?: Record) => { + if (message === 'Rejected controlled egress request' && fields) rejections.push(fields); + }) as typeof logger.warn; + let denied: Response; + try { + denied = await gatewayFetch('/https-passthrough', { + method: 'POST', + headers: { ...grantHeader(), 'Content-Type': 'application/json' }, + body, + }); + } finally { + logger.warn = originalLoggerWarn; + } expect(denied.status).toBe(403); expect(await denied.json()).toMatchObject({ error: 'HOST_NOT_ALLOWED', }); + // A refusal names what was asked for by hash, so an operator can tell which host it was. + const hash = (value: string) => + crypto.createHash('sha256').update(value, 'utf8').digest('base64url').slice(0, 16); + expect(rejections).toHaveLength(1); + expect(rejections[0]).toMatchObject({ + route: 'https-passthrough', + outcome: 'HOST_NOT_ALLOWED', + destinationHostHash: hash('unlisted.example'), + pathHash: hash('/api/optale/mcp'), + }); + expect(JSON.stringify(rejections[0])).not.toContain('unlisted.example'); }); test('rejects non-POST external-fetch methods and caller-selected envelope fields', async () => { diff --git a/service/src/egress-gateway.ts b/service/src/egress-gateway.ts index b7ad0446..e1712500 100644 --- a/service/src/egress-gateway.ts +++ b/service/src/egress-gateway.ts @@ -264,6 +264,7 @@ type EgressAuditFields = { principalSource?: string; grantHash?: string; destinationHost?: string; + destinationHostHash?: string; pathHash?: string; queryPresent?: boolean; redirectCount?: number; @@ -278,6 +279,7 @@ type ExternalFetchAuditFields = Pick< | 'userHash' | 'grantHash' | 'destinationHost' + | 'destinationHostHash' | 'pathHash' | 'queryPresent' | 'redirectCount' @@ -338,6 +340,27 @@ function auditFields(res: Response): EgressAuditFields { ); } +/** + * Hashes of the host and path a sandbox asked for, recorded before policy validation so that a + * refused request is attributable too: `destinationHostHash` is hashLabel of the lowercased host, + * comparable against hashLabel of a candidate host; `pathHash` uses the same algorithm as the + * validated one. The host itself is not logged for refusals, since code chooses it freely. + */ +function recordRequestedTarget(res: Response, rawUrl: unknown): void { + if (typeof rawUrl !== 'string') return; + let url: URL; + try { + url = new URL(rawUrl); + } catch { + return; + } + const fields = auditFields(res); + fields.destinationHostHash = hashLabel(url.hostname.toLowerCase()); + fields.pathHash = hashLabel(url.pathname); + fields.queryPresent = url.search.length > 0; + res.locals.egressAuditFields = fields; +} + function externalFetchAuditFields(res: Response): ExternalFetchAuditFields { const fields = auditFields(res); return { @@ -346,6 +369,7 @@ function externalFetchAuditFields(res: Response): ExternalFetchAuditFields { userHash: fields.userHash, grantHash: fields.grantHash, destinationHost: fields.destinationHost, + destinationHostHash: fields.destinationHostHash, pathHash: fields.pathHash, queryPresent: fields.queryPresent, redirectCount: fields.redirectCount, @@ -1675,6 +1699,7 @@ app.post('/package-transport', async (req, res) => { } const activeGrant = await getGrant(req, res); grant = activeGrant; + recordRequestedTarget(res, envelope.url); const policy = effectivePolicyForGrant(activeGrant); opened = await packageTransportOpen({ ...envelope, @@ -1840,6 +1865,7 @@ app.post('/https-passthrough', async (req, res) => { } const activeGrant = await getGrant(req, res); grant = activeGrant; + recordRequestedTarget(res, envelope.url); const policy = effectivePolicyForGrant(activeGrant); opened = await httpsPassthroughOpen({ ...envelope, @@ -2004,6 +2030,7 @@ app.post('/external-fetch', async (req, res) => { } const activeGrant = await getGrant(req, res); grant = activeGrant; + recordRequestedTarget(res, url); const policy = effectivePolicyForGrant(activeGrant); const initial = validateExternalFetchUrl(url, policy); const fields = auditFields(res); From e00ab45ea26e64fce734e4e2cdc5fb2afd2e72c4 Mon Sep 17 00:00:00 2001 From: Thor Haaland Date: Sun, 4 Oct 2026 01:38:09 +0000 Subject: [PATCH 2/2] fix(egress): host hash only on records without a validated destination; tests select by route and outcome (#17 review) --- service/src/egress-gateway.test.ts | 19 +++++++++++-------- service/src/egress-gateway.ts | 4 +++- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/service/src/egress-gateway.test.ts b/service/src/egress-gateway.test.ts index 193e57b5..51f63c86 100644 --- a/service/src/egress-gateway.test.ts +++ b/service/src/egress-gateway.test.ts @@ -1767,12 +1767,10 @@ describe('egress gateway routes', () => { close: () => undefined, })); let commitAttempts = 0; - const completionOutcomes: unknown[] = []; + const completions: Record[] = []; const originalLoggerInfo = logger.info; - logger.info = ((message: string, fields?: Record) => { - if (message === 'Egress gateway request completed' && fields?.route === 'external-fetch') { - completionOutcomes.push(fields.outcome); - } + logger.info = ((_message: string, fields?: Record) => { + if (fields?.route === 'external-fetch' && fields.outcome !== undefined) completions.push(fields); }) as typeof logger.info; setEgressFetchCommitForTest(async args => { commitAttempts += 1; @@ -1793,7 +1791,11 @@ describe('egress gateway routes', () => { expect(response.status).toBe(200); expect(response.body).toBe(body.toString('utf8')); expect(commitAttempts).toBe(2); - expect(completionOutcomes).toContain('success'); + const success = completions.find(fields => fields.outcome === 'success'); + expect(success).toMatchObject({ + destinationHost: 'temp.4d4f16c61d89ec64e760039c4ec50717.r2.cloudflarestorage.com', + }); + expect(success?.destinationHostHash).toBeUndefined(); const ledger = await assertEgressGrantActive(grant); expect(ledger.fetched_bytes).toBe(body.length); } finally { @@ -1888,8 +1890,9 @@ describe('egress gateway routes', () => { const rejections: Record[] = []; const originalLoggerWarn = logger.warn; - logger.warn = ((message: string, fields?: Record) => { - if (message === 'Rejected controlled egress request' && fields) rejections.push(fields); + logger.warn = ((_message: string, fields?: Record) => { + if (fields?.route === 'https-passthrough' && fields.outcome === 'HOST_NOT_ALLOWED') + rejections.push(fields); }) as typeof logger.warn; let denied: Response; try { diff --git a/service/src/egress-gateway.ts b/service/src/egress-gateway.ts index e1712500..cf0fcf13 100644 --- a/service/src/egress-gateway.ts +++ b/service/src/egress-gateway.ts @@ -369,7 +369,9 @@ function externalFetchAuditFields(res: Response): ExternalFetchAuditFields { userHash: fields.userHash, grantHash: fields.grantHash, destinationHost: fields.destinationHost, - destinationHostHash: fields.destinationHostHash, + // Only for requests that never reached a validated destination: once the host passed + // validation, destinationHost names it and the hash adds nothing. + destinationHostHash: fields.destinationHost ? undefined : fields.destinationHostHash, pathHash: fields.pathHash, queryPresent: fields.queryPresent, redirectCount: fields.redirectCount,