Skip to content

Commit b52d6fe

Browse files
Merge pull request #23 from HarperFast/fix/sitemap-fetch-guard-staging
feat(plugin): guard sitemap fetch, route via staging edge, identifiable UAs; v0.5.0
2 parents de2918e + 19a1a8d commit b52d6fe

9 files changed

Lines changed: 209 additions & 25 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.4.0",
3+
"version": "0.5.0",
44
"type": "module",
55
"description": "Configurable Harper plugin for prerendering pages for bots and crawlers",
66
"license": "Apache-2.0",

packages/plugin/src/config.js

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,11 @@ const defaultConfig = () => ({
119119
// The cache key does not include the header, so cache HITS always return the normal
120120
// cached page regardless of it. Empty `ip` disables the feature — production is
121121
// unaffected unless a staging IP is explicitly configured.
122+
//
123+
// The sitemap refresh reuses this `ip` too, but unconditionally (no toggle header — it has
124+
// no incoming request): whenever `ip` is set, every sitemap fetch is pinned to it, so all
125+
// Harper→origin traffic hits the same edge. The security token often only authenticates
126+
// against the staging edge, so a direct prod sitemap fetch is bounced with a 403.
122127
staging: {
123128
ip: '',
124129
header: 'x-harper-staging',
@@ -189,16 +194,25 @@ const defaultConfig = () => ({
189194
maxClaimLimit: 25,
190195
},
191196

192-
// Per-device-type User-Agent strings sent to the origin.
197+
// Per-device-type User-Agent strings sent to the origin on the proxy (cache-miss
198+
// passthrough) fetch. Each carries a `HarperProxy/1.0` product token so Harper's
199+
// proxy traffic is identifiable in origin/CDN logs while still presenting a real,
200+
// device-appropriate browser UA (the origin serves device-specific HTML off it).
193201
userAgents: {
194202
mobile:
195-
'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 HarperPrerender/1.0',
203+
'Mozilla/5.0 (Linux; Android 11; Pixel 5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/W.X.Y.Z Mobile Safari/537.36 HarperProxy/1.0',
196204
tablet:
197-
'Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36 HarperPrerender/1.0',
205+
'Mozilla/5.0 (Linux; Android 7.0; Pixel C Build/NRD90M; wv) AppleWebKit/537.36 (KHTML, like Gecko) Version/4.0 Chrome/52.0.2743.98 Safari/537.36 HarperProxy/1.0',
198206
desktop:
199-
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/W.X.Y.Z Safari/537.36 HarperPrerender/1.0',
207+
'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/W.X.Y.Z Safari/537.36 HarperProxy/1.0',
200208
},
201209

210+
// User-Agent for Harper's sitemap crawler fetch (Sitemap.refresh). Unlike the proxy
211+
// fetch above, a sitemap fetch isn't a device render, so it sends a single self-identifying
212+
// UA rather than a spoofed browser one — makes Harper's sitemap traffic obvious in
213+
// origin/CDN logs and separable from the proxy traffic.
214+
sitemapUserAgent: 'HarperSitemapCrawler/1.0',
215+
202216
// Pages whose normalized URL contains any of these substrings are never
203217
// auto-scheduled for rendering.
204218
excludePathPatterns: ['/search/'],

packages/plugin/src/resources/Sitemap.js

Lines changed: 42 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
1-
import { XMLParser } from 'fast-xml-parser';
21
import { config } from '../config.js';
32
import { RenderTarget } from './RenderTarget.js';
43
import { CacheKey } from '../util/cacheKey.js';
54
import { currentMinuteMs, getNextSitemapRefreshTime } from '../util/time.js';
5+
import { parseSitemap } from '../util/sitemap.js';
6+
import { configuredStagingIp, dispatcherFor } from '../util/upstream.js';
67

78
class Sitemap extends databases.sitemaps.Sitemap {
89
static directURLMapping = true;
@@ -202,32 +203,56 @@ function getTtlFromChangeFreq(changefreq, { minTtl, defaultTtl }) {
202203
}
203204

204205
async function fetchLatestSitemap(url) {
206+
// Route every Harper→origin sitemap fetch through the same edge as the render/origin-fetch
207+
// path: whenever a staging IP is configured, pin the TCP connection to it (Host/SNI stay the
208+
// real origin, exactly like upstream.js). The security token typically only authenticates
209+
// against the staging edge, so a direct prod fetch is bounced with a 403 "Access Denied".
210+
// Empty staging.ip → normal direct fetch (production, once the token is valid at the origin).
211+
const stagingIp = configuredStagingIp();
212+
const via = stagingIp ? ` (via staging ${stagingIp})` : '';
213+
205214
const res = await fetch(url, {
206215
method: 'GET',
207216
redirect: 'follow',
208-
headers: { 'User-Agent': 'harper-bot/1.0', [config.securityToken.header]: config.securityToken.value },
217+
headers: { 'User-Agent': config.sitemapUserAgent, [config.securityToken.header]: config.securityToken.value },
218+
dispatcher: dispatcherFor(stagingIp),
209219
});
210220
const xml = await res.text();
211221

212-
const parser = new XMLParser({
213-
isArray: (tagName) => ['sitemap', 'url'].some((value) => value === tagName),
214-
});
215-
216-
const data = parser.parse(xml);
217-
218-
const parsed = { url, lastRefreshed: new Date(), entries: [], entryCount: 0 };
222+
// A blocked/errored fetch returns an HTML error page with a 4xx/5xx status. Guard the
223+
// status AND the parsed shape so it fails loudly instead of being silently treated as an
224+
// empty sitemap (which used to return a misleading `created: 0` success).
225+
if (!res.ok) {
226+
throw new Error(`Sitemap fetch failed for ${url}${via}: ${res.status} ${res.statusText}${snippet(xml)}`);
227+
}
219228

220-
if (Array.isArray(data?.urlset?.url)) {
221-
parsed.isIndex = false;
222-
parsed.entries = data.urlset.url;
223-
} else if (Array.isArray(data?.sitemapindex?.sitemap)) {
224-
parsed.isIndex = true;
225-
parsed.entries = data.sitemapindex.sitemap;
229+
let parsed;
230+
try {
231+
parsed = parseSitemap(xml);
232+
} catch (e) {
233+
const contentType = res.headers.get('content-type') ?? 'unknown';
234+
throw new Error(
235+
`Sitemap fetch for ${url}${via} returned a non-sitemap response (status ${res.status}, content-type ${contentType}): ${e.message}${snippet(xml)}`
236+
);
226237
}
227238

228-
parsed.entryCount = parsed.entries.length;
239+
return {
240+
url,
241+
lastRefreshed: new Date(),
242+
isIndex: parsed.isIndex,
243+
entries: parsed.entries,
244+
entryCount: parsed.entries.length,
245+
};
246+
}
229247

230-
return parsed;
248+
// A short, single-line excerpt of a response body for error messages. Slice before the
249+
// whitespace-collapse so a large body (a full sitemap can be >1 MB) doesn't run the regex
250+
// over the whole string.
251+
function snippet(body, max = 200) {
252+
const raw = String(body ?? '');
253+
const truncated = raw.length > max * 2 ? raw.slice(0, max * 2) : raw;
254+
const text = truncated.replace(/\s+/g, ' ').trim();
255+
return text.length > max ? `${text.slice(0, max)}…` : text;
231256
}
232257

233258
let sitemapSchedulerStarted = false;
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { XMLParser } from 'fast-xml-parser';
2+
3+
const parser = new XMLParser({
4+
isArray: (tagName) => tagName === 'sitemap' || tagName === 'url',
5+
});
6+
7+
/**
8+
* Parse sitemap XML into `{ isIndex, entries }`.
9+
*
10+
* Throws if the document is neither a `<urlset>` nor a `<sitemapindex>` — e.g. an HTML
11+
* error/challenge page (a 403 "Access Denied" from the CDN, a login wall, a 404 page). The
12+
* old code silently treated any such body as an empty sitemap, so a blocked fetch looked
13+
* like a successful no-op refresh (`created: 0, updated: 0, …`). Failing loudly here surfaces
14+
* the real problem to the caller.
15+
*
16+
* A valid but empty `<urlset/>` / `<sitemapindex/>` parses to `entries: []` WITHOUT throwing:
17+
* fast-xml-parser renders an empty element as `''`, so presence is checked with `in`, not
18+
* truthiness (`data.urlset` is falsy for an empty sitemap). The `typeof data === 'object'`
19+
* guard keeps `in` off a non-object result — fast-xml-parser v5 always returns an object
20+
* (plain text parses to `{}`), but this stays safe if that ever changes.
21+
*/
22+
export function parseSitemap(xml) {
23+
const data = parser.parse(xml);
24+
25+
if (data && typeof data === 'object') {
26+
if ('urlset' in data) {
27+
return { isIndex: false, entries: Array.isArray(data.urlset?.url) ? data.urlset.url : [] };
28+
}
29+
if ('sitemapindex' in data) {
30+
return { isIndex: true, entries: Array.isArray(data.sitemapindex?.sitemap) ? data.sitemapindex.sitemap : [] };
31+
}
32+
}
33+
34+
const rootTags = data && typeof data === 'object' ? Object.keys(data).filter((key) => key !== '?xml') : [];
35+
throw new Error(
36+
`expected a <urlset> or <sitemapindex> root, got ${rootTags.length ? `<${rootTags.join('>, <')}>` : 'a non-XML or empty document'}`
37+
);
38+
}

packages/plugin/src/util/upstream.js

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,24 @@ export const stagingTargetIp = (headers) => {
1818
return headers?.get(header) ? ip : undefined;
1919
};
2020

21+
/**
22+
* The configured staging IP if it is set and valid, else undefined — regardless of any
23+
* request header. For callers that opt into staging out-of-band rather than via a per-request
24+
* toggle header (e.g. the sitemap refresh, which has no incoming request to carry a header).
25+
*/
26+
export const configuredStagingIp = () => {
27+
const { ip } = config.staging;
28+
return ip && isIP(ip) ? ip : undefined;
29+
};
30+
2131
// Dispatchers that pin DNS resolution to a fixed IP (staging passthrough), one per IP.
2232
// Only the connect address is overridden — the origin (so Host header + TLS SNI + cert
2333
// validation) stays the real origin host, the server-side equivalent of Chrome's
2434
// --host-resolver-rules=MAP host ip. In practice there is at most one entry (the single
2535
// configured staging IP); the map just keeps it stable across requests and across a
2636
// config reload that changes the IP.
2737
const pinnedDispatchers = new Map();
28-
const dispatcherFor = (ip) => {
38+
export const dispatcherFor = (ip) => {
2939
if (!ip) return agent;
3040
let dispatcher = pinnedDispatchers.get(ip);
3141
if (!dispatcher) {

packages/plugin/test/config.test.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,20 @@ test('applyOptions rejects type-mismatched values and keeps defaults', () => {
3131
assert.equal(config.page.ttl, 24 * 60 * 60 * 1000);
3232
});
3333

34+
test('sitemapUserAgent defaults to the Harper sitemap crawler UA and is overridable', () => {
35+
applyOptions({});
36+
assert.equal(config.sitemapUserAgent, 'HarperSitemapCrawler/1.0');
37+
applyOptions({ sitemapUserAgent: 'AcmeBot/2.0' });
38+
assert.equal(config.sitemapUserAgent, 'AcmeBot/2.0');
39+
});
40+
41+
test('proxy device UAs carry the HarperProxy product token', () => {
42+
applyOptions({});
43+
for (const ua of Object.values(config.userAgents)) {
44+
assert.match(ua, /HarperProxy\/1\.0$/);
45+
}
46+
});
47+
3448
test('applyOptions exposes analytics + url defaults', () => {
3549
applyOptions({});
3650
assert.equal(config.analytics.enabled, true);
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { test } from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { parseSitemap } from '../src/util/sitemap.js';
4+
5+
const xmlDecl = '<?xml version="1.0" encoding="UTF-8"?>';
6+
7+
test('parses a <urlset> with multiple <url> entries', () => {
8+
const { isIndex, entries } = parseSitemap(
9+
`${xmlDecl}<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">` +
10+
`<url><loc>https://x/a</loc></url><url><loc>https://x/b</loc></url></urlset>`
11+
);
12+
assert.equal(isIndex, false);
13+
assert.equal(entries.length, 2);
14+
assert.equal(entries[0].loc, 'https://x/a');
15+
});
16+
17+
test('normalizes a single <url> to a one-element array', () => {
18+
const { isIndex, entries } = parseSitemap(`${xmlDecl}<urlset><url><loc>https://x/a</loc></url></urlset>`);
19+
assert.equal(isIndex, false);
20+
assert.equal(entries.length, 1);
21+
assert.equal(entries[0].loc, 'https://x/a');
22+
});
23+
24+
test('parses a <sitemapindex> as an index', () => {
25+
const { isIndex, entries } = parseSitemap(
26+
`${xmlDecl}<sitemapindex><sitemap><loc>https://x/s1.xml</loc></sitemap></sitemapindex>`
27+
);
28+
assert.equal(isIndex, true);
29+
assert.equal(entries.length, 1);
30+
assert.equal(entries[0].loc, 'https://x/s1.xml');
31+
});
32+
33+
test('a valid but empty <urlset> yields no entries WITHOUT throwing', () => {
34+
const { isIndex, entries } = parseSitemap(`${xmlDecl}<urlset></urlset>`);
35+
assert.equal(isIndex, false);
36+
assert.deepEqual(entries, []);
37+
});
38+
39+
test('a self-closing empty <urlset/> yields no entries without throwing', () => {
40+
const { entries } = parseSitemap(`${xmlDecl}<urlset/>`);
41+
assert.deepEqual(entries, []);
42+
});
43+
44+
test('an empty <sitemapindex> yields no entries without throwing', () => {
45+
const { isIndex, entries } = parseSitemap(`${xmlDecl}<sitemapindex></sitemapindex>`);
46+
assert.equal(isIndex, true);
47+
assert.deepEqual(entries, []);
48+
});
49+
50+
test('throws on an HTML error/challenge page (the Akamai 403 case)', () => {
51+
const html = '<HTML><HEAD><TITLE>Access Denied</TITLE></HEAD><BODY><H1>Access Denied</H1></BODY></HTML>';
52+
assert.throws(() => parseSitemap(html), /expected a <urlset> or <sitemapindex> root, got <HTML>/);
53+
});
54+
55+
test('throws on an empty document', () => {
56+
assert.throws(() => parseSitemap(''), /got a non-XML or empty document/);
57+
});
58+
59+
test('throws on a plain-text (non-XML) response — e.g. a bare "Access Denied"', () => {
60+
// fast-xml-parser parses plain text to {}, so this must NOT crash on `'urlset' in data`.
61+
assert.throws(() => parseSitemap('Access Denied'), /got a non-XML or empty document/);
62+
});

packages/plugin/test/upstream.test.js

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

611
// Minimal stand-in for the request headers object (only `.get` is used here).
712
const headersWith = (present) => ({ get: (name) => (present.includes(name) ? '1' : null) });
@@ -53,6 +58,22 @@ test('stagingTargetIp supports an IPv6 staging address', () => {
5358
assert.equal(stagingTargetIp(headersWith(['x-harper-staging'])), '2606:2800:220:1:248:1893:25c8:1946');
5459
});
5560

61+
test('configuredStagingIp returns the configured ip regardless of any request header', () => {
62+
applyOptions({ staging: { ip: '23.50.51.27' } });
63+
// No header argument at all — the sitemap refresh opts in out-of-band, not via a header.
64+
assert.equal(configuredStagingIp(), '23.50.51.27');
65+
});
66+
67+
test('configuredStagingIp is undefined when no staging ip is configured', () => {
68+
applyOptions({});
69+
assert.equal(configuredStagingIp(), undefined);
70+
});
71+
72+
test('configuredStagingIp ignores an invalid configured ip', () => {
73+
applyOptions({ staging: { ip: 'not-an-ip' } });
74+
assert.equal(configuredStagingIp(), undefined);
75+
});
76+
5677
test('ignoredHeaders defaults to an empty list', () => {
5778
applyOptions({});
5879
assert.deepEqual(config.ignoredHeaders, []);

0 commit comments

Comments
 (0)