Skip to content

Commit 70c669f

Browse files
harper-josephclaude
andcommitted
fix(plugin): drop origin server-timing from proxy allowlist; correct comment
Per adversarial reanalysis: the origin's server-timing is the staging edge's own Akamai timing tokens (cdn-cache/ak_p), and the serving edge adds its own on egress — relaying the origin's risks re-doubling it and leaks Akamai tokens. Drop it from the allowlist (the final response still carries the serving edge's server-timing once the swap succeeds). Also correct the header-sanitize comment: it wrongly claimed parity with the render path. The render path strips content-encoding/content-length and re-encodes stored pages itself; the proxy path relays them for the passed-through body. Documented accurately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2742ce8 commit 70c669f

2 files changed

Lines changed: 15 additions & 14 deletions

File tree

packages/plugin/src/util/upstream.js

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -83,18 +83,22 @@ const ignoredDownstreamRequestHeaders = () => {
8383
return ignoredHeadersCache;
8484
};
8585

86-
// Origin responses are relayed to the edge verbatim on a cache miss. The origin sits
87-
// behind a CDN (Akamai), so its response carries the CDN's own singular control headers
88-
// (akamai-grn, x-akamai-*, x-cache*, via, …). When the edge's "Serve Alternate Response"
89-
// swap re-adds its own copies, those singular headers appear twice and the edge fails the
86+
// Origin responses are relayed to the edge on a cache miss. The origin sits behind a CDN
87+
// (Akamai), so its response carries the CDN's own control headers (akamai-grn, x-akamai-*,
88+
// x-cache*, via, server-timing, …). When the edge's "Serve Alternate Response" swap re-adds
89+
// its own copies the response ends up with duplicated CDN headers, and the edge fails the
9090
// transform (ERR_SWAPFAIL_*|badxform). Relay only this allowlist of genuine origin-response
91-
// headers — the same sanitization philosophy the render path already applies to stored
92-
// pages — so the swapped-in response looks like a clean origin reply. Anything not listed
93-
// (including hop-by-hop headers and set-cookie) is dropped.
91+
// headers so the swapped-in response looks like a clean origin reply; everything else (CDN
92+
// headers, hop-by-hop headers, set-cookie) is dropped.
9493
//
95-
// server-timing is intentionally kept: it is a List-type header (RFC 8941), so the inner
96-
// value and the edge's own value merge into one valid header rather than a malformed
97-
// duplicate — and it is useful for observability.
94+
// server-timing is deliberately NOT relayed: the value from the origin is the staging edge's
95+
// own Akamai timing tokens, and the serving edge adds its own on egress — so dropping the
96+
// origin's avoids re-doubling it and keeps Akamai-internal tokens off the response.
97+
//
98+
// NOTE: unlike the render path (RenderJob.allowedResponseHeaders), which strips the origin
99+
// encoding and re-encodes stored pages itself, the proxy path relays content-encoding +
100+
// content-length for the passed-through body. See the accept-encoding note in
101+
// resolveUpstreamHeaders for why the origin body is fetched gzip (not brotli).
98102
const FORWARDED_RESPONSE_HEADERS = new Set([
99103
'content-type',
100104
'content-encoding',
@@ -106,7 +110,6 @@ const FORWARDED_RESPONSE_HEADERS = new Set([
106110
'vary',
107111
'x-robots-tag',
108112
'retry-after',
109-
'server-timing',
110113
]);
111114

112115
export const sanitizeOriginResponseHeaders = (headers) => {

packages/plugin/test/upstream.test.js

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,6 @@ test('sanitizeOriginResponseHeaders keeps genuine origin headers', () => {
109109
'last-modified': 'Wed, 02 Jul 2026 00:00:00 GMT',
110110
'vary': 'Accept-Encoding',
111111
'x-robots-tag': 'noindex',
112-
'server-timing': 'cdn-cache; desc=MISS',
113112
});
114113
assert.equal(clean['content-type'], 'text/html; charset=utf-8');
115114
assert.equal(clean['content-encoding'], 'gzip');
@@ -118,8 +117,6 @@ test('sanitizeOriginResponseHeaders keeps genuine origin headers', () => {
118117
assert.equal(clean['etag'], '"abc"');
119118
assert.equal(clean['vary'], 'Accept-Encoding');
120119
assert.equal(clean['x-robots-tag'], 'noindex');
121-
// server-timing is a List-type header (mergeable), so it is kept for observability
122-
assert.equal(clean['server-timing'], 'cdn-cache; desc=MISS');
123120
});
124121

125122
test('sanitizeOriginResponseHeaders strips CDN/edge-injected headers (badxform cause)', () => {
@@ -132,6 +129,7 @@ test('sanitizeOriginResponseHeaders strips CDN/edge-injected headers (badxform c
132129
'x-cache-key': '/L/1/2/3/foo',
133130
'x-check-cacheable': 'NO',
134131
'via': '1.1 akamai.net',
132+
'server-timing': 'cdn-cache; desc=MISS',
135133
'set-cookie': 'sid=abc; Path=/',
136134
'connection': 'keep-alive',
137135
// empty duplicated custom origin headers seen in the wild — must not be forwarded

0 commit comments

Comments
 (0)