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
46 changes: 35 additions & 11 deletions service/src/egress-gateway.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1767,12 +1767,10 @@ describe('egress gateway routes', () => {
close: () => undefined,
}));
let commitAttempts = 0;
const completionOutcomes: unknown[] = [];
const completions: Record<string, unknown>[] = [];
const originalLoggerInfo = logger.info;
logger.info = ((message: string, fields?: Record<string, unknown>) => {
if (message === 'Egress gateway request completed' && fields?.route === 'external-fetch') {
completionOutcomes.push(fields.outcome);
}
logger.info = ((_message: string, fields?: Record<string, unknown>) => {
if (fields?.route === 'external-fetch' && fields.outcome !== undefined) completions.push(fields);
}) as typeof logger.info;
setEgressFetchCommitForTest(async args => {
commitAttempts += 1;
Expand All @@ -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 {
Expand Down Expand Up @@ -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<string, unknown>[] = [];
const originalLoggerWarn = logger.warn;
logger.warn = ((_message: string, fields?: Record<string, unknown>) => {
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 () => {
Expand Down
29 changes: 29 additions & 0 deletions service/src/egress-gateway.ts
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ type EgressAuditFields = {
principalSource?: string;
grantHash?: string;
destinationHost?: string;
destinationHostHash?: string;
pathHash?: string;
queryPresent?: boolean;
redirectCount?: number;
Expand All @@ -278,6 +279,7 @@ type ExternalFetchAuditFields = Pick<
| 'userHash'
| 'grantHash'
| 'destinationHost'
| 'destinationHostHash'
| 'pathHash'
| 'queryPresent'
| 'redirectCount'
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
Loading