Skip to content

Commit b49fb69

Browse files
Merge pull request #5 from HarperFast/fix/proxy-strip-cdn-response-headers
fix(plugin): strip CDN-injected headers from miss-proxy responses (v0.3.1)
2 parents 3770e22 + 70c669f commit b49fb69

4 files changed

Lines changed: 103 additions & 10 deletions

File tree

package-lock.json

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@harperfast/prerender",
3-
"version": "0.3.0",
3+
"version": "0.3.1",
44
"type": "module",
55
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
66
"license": "Apache-2.0",

packages/plugin/src/util/upstream.js

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

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
90+
// transform (ERR_SWAPFAIL_*|badxform). Relay only this allowlist of genuine origin-response
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.
93+
//
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).
102+
const FORWARDED_RESPONSE_HEADERS = new Set([
103+
'content-type',
104+
'content-encoding',
105+
'content-length',
106+
'cache-control',
107+
'expires',
108+
'etag',
109+
'last-modified',
110+
'vary',
111+
'x-robots-tag',
112+
'retry-after',
113+
]);
114+
115+
export const sanitizeOriginResponseHeaders = (headers) => {
116+
const clean = {};
117+
if (!headers) return clean;
118+
// HTTP header names are case-insensitive; match the allowlist on a lowercased key
119+
// (undici lowercases already, but a future caller may not).
120+
for (const [key, value] of Object.entries(headers)) {
121+
if (value === undefined) continue;
122+
const name = key.toLowerCase();
123+
if (FORWARDED_RESPONSE_HEADERS.has(name)) clean[name] = value;
124+
}
125+
return clean;
126+
};
127+
86128
export const resolveUpstreamHeaders = (downstream, deviceType) => {
87129
const upstream = {
88130
'user-agent': config.userAgents[deviceType] ?? config.userAgents.desktop,
89131
[config.securityToken.header]: config.securityToken.value,
90-
'accept-encoding': 'br, gzip',
132+
// Request gzip (not brotli) from the origin. On a cache miss this response is relayed
133+
// to the Akamai edge for its "Serve Alternate Response" swap, and Akamai cannot apply
134+
// its outgoing transform to a brotli-encoded alternate response (ERR_SWAPFAIL_*|badxform).
135+
// gzip is transform-safe; the edge re-compresses (to br) for the real client on egress.
136+
'accept-encoding': 'gzip',
91137
};
92138

93139
if (downstream) {
@@ -120,17 +166,12 @@ export const fetchOriginResource = async (request) => {
120166
body,
121167
});
122168

123-
for (const key of hopByHopHeaders) {
124-
delete response.headers[key];
125-
}
126-
delete response.headers['set-cookie'];
127-
128169
return {
129170
miss: true,
130171
url: urlObj.href,
131172
deviceType,
132173
statusCode: response.statusCode,
133-
headers: response.headers,
174+
headers: sanitizeOriginResponseHeaders(response.headers),
134175
content: Readable.toWeb(response.body),
135176
viaStaging: Boolean(stagingIp),
136177
};

packages/plugin/test/upstream.test.js

Lines changed: 53 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { test } from 'node:test';
22
import assert from 'node:assert/strict';
33
import { applyOptions, config } from '../src/config.js';
4-
import { resolveUpstreamHeaders, stagingTargetIp } from '../src/util/upstream.js';
4+
import { resolveUpstreamHeaders, sanitizeOriginResponseHeaders, stagingTargetIp } from '../src/util/upstream.js';
55

66
// Minimal stand-in for the request headers object (only `.get` is used here).
77
const headersWith = (present) => ({ get: (name) => (present.includes(name) ? '1' : null) });
@@ -99,6 +99,58 @@ test('resolveUpstreamHeaders matches ignoredHeaders case-insensitively', () => {
9999
assert.equal(upstream['x-internal'], undefined);
100100
});
101101

102+
test('sanitizeOriginResponseHeaders keeps genuine origin headers', () => {
103+
const clean = sanitizeOriginResponseHeaders({
104+
'content-type': 'text/html; charset=utf-8',
105+
'content-encoding': 'gzip',
106+
'content-length': '1234',
107+
'cache-control': 'max-age=60',
108+
'etag': '"abc"',
109+
'last-modified': 'Wed, 02 Jul 2026 00:00:00 GMT',
110+
'vary': 'Accept-Encoding',
111+
'x-robots-tag': 'noindex',
112+
});
113+
assert.equal(clean['content-type'], 'text/html; charset=utf-8');
114+
assert.equal(clean['content-encoding'], 'gzip');
115+
assert.equal(clean['content-length'], '1234');
116+
assert.equal(clean['cache-control'], 'max-age=60');
117+
assert.equal(clean['etag'], '"abc"');
118+
assert.equal(clean['vary'], 'Accept-Encoding');
119+
assert.equal(clean['x-robots-tag'], 'noindex');
120+
});
121+
122+
test('sanitizeOriginResponseHeaders strips CDN/edge-injected headers (badxform cause)', () => {
123+
const clean = sanitizeOriginResponseHeaders({
124+
'content-type': 'text/html',
125+
'akamai-grn': '0.1234abcd',
126+
'x-akamai-staging': 'ESSL',
127+
'x-akamai-transformed': '9 0 0',
128+
'x-cache': 'TCP_MISS from a1-2-3-4',
129+
'x-cache-key': '/L/1/2/3/foo',
130+
'x-check-cacheable': 'NO',
131+
'via': '1.1 akamai.net',
132+
'server-timing': 'cdn-cache; desc=MISS',
133+
'set-cookie': 'sid=abc; Path=/',
134+
'connection': 'keep-alive',
135+
// empty duplicated custom origin headers seen in the wild — must not be forwarded
136+
'x-origin-cc': '',
137+
'x-origin-ttl': '',
138+
});
139+
assert.deepEqual(Object.keys(clean), ['content-type']);
140+
});
141+
142+
test('sanitizeOriginResponseHeaders returns {} for null/undefined input', () => {
143+
assert.deepEqual(sanitizeOriginResponseHeaders(null), {});
144+
assert.deepEqual(sanitizeOriginResponseHeaders(undefined), {});
145+
});
146+
147+
test('sanitizeOriginResponseHeaders matches allowlist case-insensitively (normalizes key)', () => {
148+
const clean = sanitizeOriginResponseHeaders({ 'Content-Type': 'text/html', 'X-Akamai-Staging': 'ESSL' });
149+
assert.equal(clean['content-type'], 'text/html');
150+
assert.equal(clean['x-akamai-staging'], undefined);
151+
assert.equal(clean['X-Akamai-Staging'], undefined);
152+
});
153+
102154
test('resolveUpstreamHeaders drops a spoofed token/debug header even when configured mixed-case', () => {
103155
// Incoming keys are lowercased, so a mixed-case configured name must still match.
104156
applyOptions({ securityToken: { header: 'X-Harper-Token', value: 'real' }, debugHeader: { key: 'X-Harper-Debug' } });

0 commit comments

Comments
 (0)