diff --git a/service/src/egress-gateway.test.ts b/service/src/egress-gateway.test.ts index 34d03805..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 { @@ -1886,15 +1888,37 @@ 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 (fields?.route === 'https-passthrough' && fields.outcome === 'HOST_NOT_ALLOWED') + 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..cf0fcf13 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,9 @@ function externalFetchAuditFields(res: Response): ExternalFetchAuditFields { userHash: fields.userHash, grantHash: fields.grantHash, destinationHost: fields.destinationHost, + // 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, @@ -1675,6 +1701,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 +1867,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 +2032,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);