Audit crawler: send a browser navigation header set (tier-1 anti-bot)
Some checks failed
CI / ci (push) Has been cancelled
CI / docker-build (push) Has been cancelled
Publish Docker image / docker (push) Has been cancelled
Upload sourcemaps / upload (push) Has been cancelled

The crawler identified as `OpenSEO-Audit/1.0` with almost no headers, which
naive bot filters and security plugins block outright.

- New crawl-request.ts: AUDIT_USER_AGENT (a current Chrome string),
  buildAuditHeaders() (Accept, Accept-Language, Sec-Fetch-*, Sec-Ch-Ua,
  Upgrade-Insecure-Requests), and fetchForAudit() — fetch + those headers +
  one retry on a transient 429/503.
- Wired into the page crawl (site-audit-workflow-helpers), robots.txt +
  sitemap discovery, and start-URL redirect probing.

Gets past the naive tier; still reported as "blocked" for JS/TLS challenges
(Cloudflare Managed Challenge, DataDome) — those need a real browser. Doc note
points operators at WAF IP/UA allowlisting for their own sites.

No env dependency (keeps the audit lib importable without a cloudflare:workers
mock). tsc / oxlint / knip clean; new crawl-request.test.ts (5); suite
otherwise unchanged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
metatroncubeswdev 2026-09-10 16:35:50 -04:00
parent ce75d45141
commit 17dfca406f
6 changed files with 143 additions and 9 deletions

View File

@ -92,6 +92,10 @@ app keeps working; the Activity tab just shows nothing.
- Password reset by email is not available in `team` mode. The owner/admins - Password reset by email is not available in `team` mode. The owner/admins
reset passwords from Settings → Users. reset passwords from Settings → Users.
- Site audit sends a Chrome-like User-Agent and browser headers, which gets
past naive bot filters but not JS/TLS challenges (Cloudflare Managed
Challenge, DataDome). For those, allowlist the server's IP or the crawler's
User-Agent in the target site's WAF.
- The MCP server and its OAuth flow are hosted-only for now; `team` deployments - The MCP server and its OAuth flow are hosted-only for now; `team` deployments
serve the app UI only. serve the app UI only.
- Rolling back: set `AUTH_MODE=local_noauth`, rebuild, restart. Existing users - Rolling back: set `AUTH_MODE=local_noauth`, rebuild, restart. Existing users

View File

@ -0,0 +1,82 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
AUDIT_USER_AGENT,
buildAuditHeaders,
fetchForAudit,
} from "./crawl-request";
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
describe("buildAuditHeaders", () => {
it("sends a browser navigation header set", () => {
const headers = buildAuditHeaders();
expect(headers["User-Agent"]).toBe(AUDIT_USER_AGENT);
expect(AUDIT_USER_AGENT).toMatch(/Chrome\/\d+\.0\.0\.0 Safari/);
expect(headers["Accept-Language"]).toContain("en");
expect(headers["Sec-Fetch-Mode"]).toBe("navigate");
expect(headers["Upgrade-Insecure-Requests"]).toBe("1");
});
it("lets callers override individual entries", () => {
expect(buildAuditHeaders({ Accept: "text/plain" }).Accept).toBe(
"text/plain",
);
});
});
describe("fetchForAudit", () => {
it("returns the first response when it is not a transient block", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
const res = await fetchForAudit("https://example.com");
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it("applies the browser headers", async () => {
let seen: HeadersInit | undefined;
vi.stubGlobal(
"fetch",
vi.fn((_url: string, init?: RequestInit) => {
seen = init?.headers;
return Promise.resolve(new Response("ok", { status: 200 }));
}),
);
await fetchForAudit("https://example.com");
expect(seen).toMatchObject({ "User-Agent": AUDIT_USER_AGENT });
});
it("retries once on 503 and returns the retry response", async () => {
const fetchMock = vi
.fn()
.mockResolvedValueOnce(new Response("busy", { status: 503 }))
.mockResolvedValueOnce(new Response("ok", { status: 200 }));
vi.stubGlobal("fetch", fetchMock);
vi.useFakeTimers();
const promise = fetchForAudit("https://example.com");
await vi.runAllTimersAsync();
const res = await promise;
expect(res.status).toBe(200);
expect(fetchMock).toHaveBeenCalledTimes(2);
});
it("does not retry a hard 403", async () => {
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("no", { status: 403 }));
vi.stubGlobal("fetch", fetchMock);
const res = await fetchForAudit("https://example.com");
expect(res.status).toBe(403);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,48 @@
// Tier-1 anti-bot handling for the site-audit crawler: present each request as
// an ordinary top-level Chrome navigation. This gets past naive user-agent
// blocklists, basic WAF rules, and security plugins. It does NOT defeat JS or
// TLS-fingerprint challenges (Cloudflare Managed Challenge, DataDome, Akamai) —
// those need a real browser and are still reported as "blocked".
const CHROME_MAJOR = "141";
export const AUDIT_USER_AGENT =
`Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 ` +
`(KHTML, like Gecko) Chrome/${CHROME_MAJOR}.0.0.0 Safari/537.36`;
export function buildAuditHeaders(
overrides: Record<string, string> = {},
): Record<string, string> {
return {
"User-Agent": AUDIT_USER_AGENT,
Accept:
"text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "en-US,en;q=0.9",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Sec-Ch-Ua": `"Chromium";v="${CHROME_MAJOR}", "Google Chrome";v="${CHROME_MAJOR}", "Not?A_Brand";v="24"`,
"Sec-Ch-Ua-Mobile": "?0",
"Sec-Ch-Ua-Platform": '"Windows"',
...overrides,
};
}
const TRANSIENT_BLOCK_STATUS = new Set([429, 503]);
// fetch() with browser headers and a single retry on a transient throttle /
// challenge response. A hard block (403, persistent 429) will not clear, so the
// retry is capped tight — ~1s of added latency at most.
export async function fetchForAudit(
url: string,
init: RequestInit & { headers?: Record<string, string> } = {},
): Promise<Response> {
const headers = buildAuditHeaders(init.headers);
const first = await fetch(url, { ...init, headers });
if (!TRANSIENT_BLOCK_STATUS.has(first.status)) return first;
await new Promise((resolve) => setTimeout(resolve, 1_000));
return fetch(url, { ...init, headers });
}

View File

@ -3,6 +3,7 @@
*/ */
import robotsParser from "robots-parser"; import robotsParser from "robots-parser";
import { XMLParser } from "fast-xml-parser"; import { XMLParser } from "fast-xml-parser";
import { buildAuditHeaders } from "./crawl-request";
import { isSameOrigin, normalizeUrl } from "./url-utils"; import { isSameOrigin, normalizeUrl } from "./url-utils";
const SITEMAP_FETCH_TIMEOUT_MS = 15_000; const SITEMAP_FETCH_TIMEOUT_MS = 15_000;
@ -40,7 +41,7 @@ export interface RobotsResult {
async function fetchRobotsTxtText(origin: string): Promise<string | null> { async function fetchRobotsTxtText(origin: string): Promise<string | null> {
try { try {
const response = await fetch(`${origin}/robots.txt`, { const response = await fetch(`${origin}/robots.txt`, {
headers: { "User-Agent": "OpenSEO-Audit/1.0" }, headers: buildAuditHeaders({ Accept: "text/plain,*/*;q=0.8" }),
signal: AbortSignal.timeout(10_000), signal: AbortSignal.timeout(10_000),
}); });
@ -173,7 +174,9 @@ async function fetchSitemapDocumentWithRetry(sitemapUrl: string): Promise<{
for (let attempt = 0; attempt <= SITEMAP_RETRIES; attempt++) { for (let attempt = 0; attempt <= SITEMAP_RETRIES; attempt++) {
try { try {
const response = await fetch(normalizedSitemapUrl, { const response = await fetch(normalizedSitemapUrl, {
headers: { "User-Agent": "OpenSEO-Audit/1.0" }, headers: buildAuditHeaders({
Accept: "application/xml,text/xml;q=0.9,*/*;q=0.8",
}),
signal: AbortSignal.timeout(SITEMAP_FETCH_TIMEOUT_MS), signal: AbortSignal.timeout(SITEMAP_FETCH_TIMEOUT_MS),
}); });

View File

@ -1,4 +1,5 @@
import { AppError } from "@/server/lib/errors"; import { AppError } from "@/server/lib/errors";
import { buildAuditHeaders } from "./crawl-request";
const BLOCKED_HOSTS = new Set([ const BLOCKED_HOSTS = new Set([
"localhost", "localhost",
@ -263,7 +264,7 @@ export async function resolveStartUrlRedirects(
response = await fetch(current, { response = await fetch(current, {
method: "HEAD", method: "HEAD",
redirect: "manual", redirect: "manual",
headers: { "User-Agent": "OpenSEO-Audit/1.0" }, headers: buildAuditHeaders(),
signal: AbortSignal.timeout(START_URL_PROBE_TIMEOUT_MS), signal: AbortSignal.timeout(START_URL_PROBE_TIMEOUT_MS),
}); });
} catch { } catch {

View File

@ -2,10 +2,10 @@ import type {
CrawledPageResult, CrawledPageResult,
PageFetchClass, PageFetchClass,
} from "@/server/lib/audit/types"; } from "@/server/lib/audit/types";
import { fetchForAudit } from "@/server/lib/audit/crawl-request";
import { sha256Hex } from "@/server/lib/audit/ids"; import { sha256Hex } from "@/server/lib/audit/ids";
import { normalizeUrl } from "@/server/lib/audit/url-utils"; import { normalizeUrl } from "@/server/lib/audit/url-utils";
const CRAWL_USER_AGENT = "OpenSEO-Audit/1.0";
const MAX_HTML_BYTES = 1024 * 1024; const MAX_HTML_BYTES = 1024 * 1024;
/** /**
@ -69,11 +69,7 @@ export async function crawlPage(
// /docs/) need no special handling: normalizeUrl preserves trailing // /docs/) need no special handling: normalizeUrl preserves trailing
// slashes, so /docs and /docs/ are distinct URLs and the redirect resolves // slashes, so /docs and /docs/ are distinct URLs and the redirect resolves
// to its canonical target instead of cycling back to its own source. // to its canonical target instead of cycling back to its own source.
const response = await fetch(url, { const response = await fetchForAudit(url, {
headers: {
"User-Agent": CRAWL_USER_AGENT,
Accept: "text/html,application/xhtml+xml",
},
redirect: "manual", redirect: "manual",
signal: AbortSignal.timeout(15_000), signal: AbortSignal.timeout(15_000),
}); });