Skip to content

Commit ef9a94f

Browse files
committed
Canonical origin nice-to-haves
1 parent 75cd0e0 commit ef9a94f

3 files changed

Lines changed: 66 additions & 2 deletions

File tree

src/cli/commands/check.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ export function registerCheckCommand(program: Command): void {
3939
.option('--score', 'Include scoring data in JSON output')
4040
.option(
4141
'--canonical-origin <url>',
42-
'Rewrite this origin to the target in fetched content (for preview/staging testing)',
42+
'The production domain your content links to (for preview/staging testing)',
4343
)
4444
.action(async (rawUrl: string | undefined, opts: Record<string, unknown>) => {
4545
// Load config: explicit path or auto-discover
@@ -185,6 +185,13 @@ export function registerCheckCommand(program: Command): void {
185185
process.exitCode = 1;
186186
return;
187187
}
188+
const targetOrigin = new URL(url).origin;
189+
if (canonicalOrigin === targetOrigin) {
190+
process.stderr.write(
191+
`Warning: --canonical-origin "${canonicalOrigin}" is the same as the target origin. The flag has no effect.\n`,
192+
);
193+
canonicalOrigin = undefined;
194+
}
188195
}
189196

190197
const report = await runChecks(url, {

src/http.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ export function createHttpClient(options: RateLimitedHttpClientOptions): HttpCli
2626
let activeRequests = 0;
2727
const originPattern =
2828
options.canonicalOrigin && options.targetOrigin
29-
? new RegExp(escapeRegExp(options.canonicalOrigin), 'g')
29+
? new RegExp(escapeRegExp(options.canonicalOrigin) + '(?=[/\\s"\'\\]>]|$)', 'g')
3030
: null;
3131

3232
async function waitForSlot(): Promise<void> {

test/unit/helpers/http.test.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,63 @@ describe('createHttpClient', () => {
153153
expect(response.redirected).toBe(true);
154154
});
155155

156+
it('rewrites origins that include a port', async () => {
157+
const body = 'See https://prod.example.com:8080/docs/guide for details.';
158+
globalThis.fetch = vi.fn(async () => makeTextResponse(body, { contentType: 'text/plain' }));
159+
160+
const client = createHttpClient({
161+
requestDelay: 0,
162+
requestTimeout: 5000,
163+
maxConcurrency: 10,
164+
canonicalOrigin: 'https://prod.example.com:8080',
165+
targetOrigin: 'http://localhost:3000',
166+
});
167+
const response = await client.fetch('http://localhost:3000/docs/guide');
168+
const text = await response.text();
169+
170+
expect(text).toBe('See http://localhost:3000/docs/guide for details.');
171+
});
172+
173+
it('does not match a longer domain that starts with the canonical origin', async () => {
174+
const body = [
175+
'https://prod.example.com/docs/guide',
176+
'https://prod.example.com.evil.com/phishing',
177+
].join('\n');
178+
globalThis.fetch = vi.fn(async () => makeTextResponse(body, { contentType: 'text/plain' }));
179+
180+
const client = createHttpClient({
181+
requestDelay: 0,
182+
requestTimeout: 5000,
183+
maxConcurrency: 10,
184+
canonicalOrigin: 'https://prod.example.com',
185+
targetOrigin: 'https://preview.local',
186+
});
187+
const response = await client.fetch('http://preview.local/docs');
188+
const text = await response.text();
189+
190+
expect(text).toContain('https://preview.local/docs/guide');
191+
expect(text).toContain('https://prod.example.com.evil.com/phishing');
192+
});
193+
194+
it('returns the same rewritten body on multiple text() calls', async () => {
195+
const body = 'Link: https://prod.example.com/page';
196+
globalThis.fetch = vi.fn(async () => makeTextResponse(body, { contentType: 'text/plain' }));
197+
198+
const client = createHttpClient({
199+
requestDelay: 0,
200+
requestTimeout: 5000,
201+
maxConcurrency: 10,
202+
canonicalOrigin: 'https://prod.example.com',
203+
targetOrigin: 'https://preview.local',
204+
});
205+
const response = await client.fetch('http://preview.local/page');
206+
const first = await response.text();
207+
const second = await response.text();
208+
209+
expect(first).toBe('Link: https://preview.local/page');
210+
expect(second).toBe(first);
211+
});
212+
156213
it('skips rewrite for non-text content types', async () => {
157214
const original = 'https://prod.example.com/binary-data';
158215
globalThis.fetch = vi.fn(async () =>

0 commit comments

Comments
 (0)