fix(routing): enforce slashless public URLs
This commit is contained in:
5 files changed
+87
-5
No files matched your search
@@ -24,6 +24,18 @@ function formActions(html) {
|
||||
match[1].match(/\baction=["']([^"']*)["']/i)?.[1] ?? null);
|
||||
}
|
||||
|
||||
function rootRelativeUrls(html) {
|
||||
const urls = [];
|
||||
for (const match of html.matchAll(/\b(?:href|action)=["']([^"']+)["']/gi)) urls.push(match[1]);
|
||||
return urls;
|
||||
}
|
||||
|
||||
function hasTerminalSlash(url) {
|
||||
if (!url.startsWith('/') || url.startsWith('//')) return false;
|
||||
const pathname = url.split(/[?#]/, 1)[0];
|
||||
return pathname !== '/' && pathname.endsWith('/');
|
||||
}
|
||||
|
||||
function unsafeSignals(html) {
|
||||
const signals = [];
|
||||
if (/<iframe\b[^>]*\bsrc=["'][^"']*jotform/i.test(html)) signals.push('active Jotform iframe');
|
||||
@@ -52,10 +64,12 @@ export async function verifyArtifact(root, mode = 'staging') {
|
||||
const title = html.match(/<title>([^<]*)<\/title>/i)?.[1]?.trim() ?? '';
|
||||
const actions = formActions(html);
|
||||
const nonSearchActions = actions.filter((action) => action && action !== '/search');
|
||||
const trailingSlashUrls = rootRelativeUrls(html).filter(hasTerminalSlash);
|
||||
const signals = unsafeSignals(html);
|
||||
if (!title) errors.push(`${route}: missing title`);
|
||||
if (!canonical) errors.push(`${route}: missing canonical`);
|
||||
if (!robots) errors.push(`${route}: missing robots directive`);
|
||||
if (trailingSlashUrls.length) errors.push(`${route}: root-relative URL(s) end in a slash: ${trailingSlashUrls.join(', ')}`);
|
||||
if (mode === 'staging' && robots !== 'noindex,nofollow') errors.push(`${route}: staging robots is ${robots ?? 'missing'}`);
|
||||
if (canonical) {
|
||||
const prior = canonicalMap.get(canonical);
|
||||
|
||||
@@ -49,6 +49,14 @@ test('artifact rejects live form mode and production API origin', async () => {
|
||||
assert.ok(result.errors.some((error) => error.includes('unsafe artifact signals')));
|
||||
});
|
||||
|
||||
test('artifact rejects root-relative URLs with terminal slashes', async () => {
|
||||
const root = await fixture('staging');
|
||||
await fs.appendFile(path.join(root, 'index.html'), '<a href="/lp/aba-therapy/">Campaign</a>');
|
||||
const result = await verifyArtifact(root, 'staging');
|
||||
assert.equal(result.passed, false);
|
||||
assert.ok(result.errors.some((error) => error.includes('root-relative URL(s) end in a slash')));
|
||||
});
|
||||
|
||||
test('artifact digest is deterministic for unchanged files', async () => {
|
||||
const root = await fixture('staging');
|
||||
const first = await verifyArtifact(root, 'staging');
|
||||
|
||||
@@ -23,9 +23,15 @@ await fs.symlink(releaseA, current);
|
||||
const contentTypes = { '.html': 'text/html', '.xml': 'application/xml', '.txt': 'text/plain', '.css': 'text/css', '.js': 'text/javascript', '.svg': 'image/svg+xml', '.webp': 'image/webp', '.png': 'image/png', '.jpg': 'image/jpeg', '.woff2': 'font/woff2' };
|
||||
const server = http.createServer(async (request, response) => {
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL(request.url ?? '/', 'http://127.0.0.1').pathname);
|
||||
const clean = pathname.replace(/^\/+/, '');
|
||||
const candidates = [clean, clean.endsWith('/') ? `${clean}index.html` : `${clean}/index.html`, clean === '' ? 'index.html' : null].filter(Boolean);
|
||||
const requestUrl = new URL(request.url ?? '/', 'http://127.0.0.1');
|
||||
const pathname = requestUrl.pathname;
|
||||
if (pathname !== '/' && /\/+$/u.test(pathname)) {
|
||||
response.writeHead(308, { location: `${pathname.replace(/\/+$/u, '')}${requestUrl.search}`, 'cache-control': 'no-store' });
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
const clean = decodeURIComponent(pathname).replace(/^\/+/, '');
|
||||
const candidates = [clean, clean === '' ? 'index.html' : `${clean}/index.html`, clean === '' ? null : `${clean}.html`].filter(Boolean);
|
||||
let target;
|
||||
for (const candidate of candidates) {
|
||||
const candidatePath = path.resolve(current, candidate);
|
||||
@@ -41,7 +47,7 @@ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
const address = server.address();
|
||||
const origin = `http://127.0.0.1:${address.port}`;
|
||||
const request = (pathname) => new Promise((resolve, reject) => {
|
||||
const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], body: Buffer.concat(chunks).toString('utf8') })); });
|
||||
const req = http.get(origin + pathname, (res) => { const chunks = []; res.on('data', (chunk) => chunks.push(chunk)); res.on('end', () => resolve({ status: res.statusCode, type: res.headers['content-type'], cache: res.headers['cache-control'], location: res.headers.location, body: Buffer.concat(chunks).toString('utf8') })); });
|
||||
req.on('error', reject);
|
||||
});
|
||||
const checks = [];
|
||||
@@ -49,7 +55,20 @@ const check = async (name, fn) => { await fn(); checks.push(name); };
|
||||
await check('staging artifact verifier', async () => assert.equal((await verifyArtifact(releaseA, 'staging')).passed, true));
|
||||
await check('root route', async () => assert.equal((await request('/')).status, 200));
|
||||
await check('locale route', async () => assert.equal((await request('/es')).status, 200));
|
||||
await check('slashless and trailing-slash routes', async () => { assert.equal((await request('/library')).status, 200); assert.equal((await request('/library/')).status, 200); });
|
||||
await check('slashless routes serve and trailing-slash variants redirect', async () => {
|
||||
assert.equal((await request('/library')).status, 200);
|
||||
const libraryTrailing = await request('/library/');
|
||||
assert.equal(libraryTrailing.status, 308);
|
||||
assert.equal(libraryTrailing.location, '/library');
|
||||
const landingTrailing = await request('/lp/aba-therapy/');
|
||||
assert.equal(landingTrailing.status, 308);
|
||||
assert.equal(landingTrailing.location, '/lp/aba-therapy');
|
||||
});
|
||||
await check('trailing-slash redirects preserve query strings', async () => {
|
||||
const response = await request('/search/?term=aba&lang=es');
|
||||
assert.equal(response.status, 308);
|
||||
assert.equal(response.location, '/search?term=aba&lang=es');
|
||||
});
|
||||
await check('query strings preserved by static serving', async () => assert.equal((await request('/search?term=aba&lang=es')).status, 200));
|
||||
await check('asset MIME type', async () => assert.match((await request('/assets/images/favicon.svg')).type, /image\/svg/));
|
||||
await check('robots and empty staging sitemap', async () => { const robots = await request('/robots.txt'); const sitemap = await request('/sitemap.xml'); assert.equal(robots.status, 200); assert.equal((sitemap.body.match(/<url>/g) ?? []).length, 0); });
|
||||
|
||||
Reference in new issue
Block a user