Stop reporting bot-scanner 404s to Sentry

Automated scanners probing for WordPress paths (/blog/wp/v2/posts/*,
POST /) generate a React Router 404 or 405 on every hit, and handleError
forwarded all of them to Sentry, exhausting the quota. The existing
defence was a list of ignoreErrors regexes that needed a new entry for
each scanner pattern.

Filter on what the error is instead: shouldReportServerError drops 4xx
responses React Router generated itself (internal: true) for requests
that matched nothing. Responses the app throws deliberately still report,
as do all 5xx and real exceptions.

A request carrying a same-origin Referer is still reported, so a broken
internal link remains visible in Sentry -- only cold scanner hits are
dropped. No HTTP response changes; every URL returns the status and page
it did before.

Also adds /blog to the Express bot-probe filter so the highest-volume
path 404s without an SSR render, and removes the now-redundant
route-404 ignoreErrors entries.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ESrxBMZMx2BD9rcKTCgLe4
This commit is contained in:
Claude 2026-09-14 20:11:46 +00:00
parent 2639102f06
commit 41a0237a87
No known key found for this signature in database
5 changed files with 215 additions and 10 deletions

View file

@ -1,18 +1,26 @@
import * as Sentry from "@sentry/react-router";
import { PassThrough } from "node:stream";
import { logger } from "~/lib/logger";
import { shouldReportServerError } from "~/lib/error-reporting";
import type { AppLoadContext, EntryContext } from "react-router";
import type { AppLoadContext, EntryContext, HandleErrorFunction } from "react-router";
import { createReadableStreamFromReadable } from "@react-router/node";
import { ServerRouter } from "react-router";
import { isbot } from "isbot";
import type { RenderToPipeableStreamOptions } from "react-dom/server";
import { renderToPipeableStream } from "react-dom/server";
export const handleError = Sentry.createSentryHandleError({
const sentryHandleError = Sentry.createSentryHandleError({
logErrors: true,
});
export const handleError: HandleErrorFunction = (error, args) => {
// Unrecognised URLs and methods are bot scans, not bugs. Skipping early also
// keeps them out of the `logErrors` console output; morgan still logs the request.
if (!shouldReportServerError(error, args.request)) return;
return sentryHandleError(error, args);
};
export const streamTimeout = 5_000;
async function handleRequest(

View file

@ -0,0 +1,152 @@
import { describe, it, expect } from "vitest";
import { createStaticHandler } from "react-router";
import { shouldReportServerError } from "../error-reporting";
const ORIGIN = "https://brackt.com";
/** Shaped like the ErrorResponse React Router hands to `handleError`. */
function routeError(
status: number,
internal: boolean,
statusText = "Not Found",
) {
return {
status,
statusText,
internal,
data: `Error: No route matches URL "/blog/wp/v2/posts/999999"`,
};
}
function request(path: string, referer?: string, method = "GET") {
return new Request(`${ORIGIN}${path}`, {
method,
headers: referer ? { referer } : {},
});
}
describe("shouldReportServerError", () => {
it("drops a router 404 for a scanner hitting a URL cold", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/wp/v2/posts/999999"),
),
).toBe(false);
});
it("drops a router 404 linked from another site", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/", "https://evil.example/"),
),
).toBe(false);
});
it("reports a router 404 linked from one of our own pages", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/leagues/gone", `${ORIGIN}/leagues`),
),
).toBe(true);
});
it("drops the 405 from a POST to a route with no action", () => {
expect(
shouldReportServerError(
routeError(405, true, "Method Not Allowed"),
request("/", undefined, "POST"),
),
).toBe(false);
});
it("reports a 404 the app threw deliberately", () => {
expect(
shouldReportServerError(
routeError(404, false),
request("/leagues/missing"),
),
).toBe(true);
});
it("reports a 403 the app threw from an ownership check", () => {
expect(
shouldReportServerError(
routeError(403, false, "Forbidden"),
request("/admin/sports"),
),
).toBe(true);
});
it("reports a router-internal 500", () => {
expect(
shouldReportServerError(
routeError(500, true, "Internal Server Error"),
request("/leagues"),
),
).toBe(true);
});
it("reports a plain exception", () => {
expect(
shouldReportServerError(new Error("boom"), request("/leagues")),
).toBe(true);
});
it("reports anything that is not a route error response", () => {
expect(shouldReportServerError("just a string", request("/leagues"))).toBe(
true,
);
expect(shouldReportServerError(null, request("/leagues"))).toBe(true);
});
it("drops a router 404 whose referer header is not a URL", () => {
expect(
shouldReportServerError(
routeError(404, true),
request("/blog/", "not a url"),
),
).toBe(false);
});
});
/**
* The unit tests above use hand-written error objects. These drive real requests
* through React Router so the suite fails if the shape it throws ever changes.
*/
describe("shouldReportServerError against real React Router errors", () => {
const handler = createStaticHandler([
{
id: "root",
path: "/",
children: [{ id: "home", index: true, loader: () => null }],
},
]);
async function errorFor(req: Request) {
const ctx = await handler.query(req);
if (ctx instanceof Response) return null;
return Object.values(ctx.errors ?? {})[0] ?? null;
}
it('drops the 404 for an unmatched URL (No route matches URL "...")', async () => {
const req = request("/blog/wp/v2/posts/999999");
const error = await errorFor(req);
expect(error).toMatchObject({ status: 404, internal: true });
expect(shouldReportServerError(error, req)).toBe(false);
});
it("reports the same 404 when it came from a link on our own site", async () => {
const req = request("/nope", `${ORIGIN}/leagues`);
expect(shouldReportServerError(await errorFor(req), req)).toBe(true);
});
it("drops the 405 from a POST to a route with no action", async () => {
const req = request("/", undefined, "POST");
const error = await errorFor(req);
expect(error).toMatchObject({ status: 405, internal: true });
expect(shouldReportServerError(error, req)).toBe(false);
});
});

View file

@ -0,0 +1,49 @@
/**
* Decides which server-side errors are worth sending to Sentry.
*
* Automated scanners probe for CMS paths that have never existed here
* (`/blog/wp/v2/posts/999999`, `/wp-login.php`, a bare `POST /`). React Router
* throws for each one a 404 when no route matches, a 405 when a route has no
* `action` and every throw reaches `handleError` in `app/entry.server.tsx`.
* Reporting those burns the Sentry quota without ever describing a real bug.
*/
import { isRouteErrorResponse } from "react-router";
/** React Router stamps `internal: true` on the errors it generates itself. */
function isInternalRouterError(error: unknown): boolean {
return (error as { internal?: unknown }).internal === true;
}
/** True when the request was linked from a page on this same origin. */
function hasSameOriginReferer(request: Request): boolean {
const referer = request.headers.get("referer");
if (!referer) return false;
try {
return new URL(referer).origin === new URL(request.url).origin;
} catch {
// Scanners send garbage in this header; a referer we can't parse isn't ours.
return false;
}
}
/**
* Whether `error` should be reported to Sentry.
*
* Drops only the 4xx responses React Router generated for a request that
* matched nothing that is, unrecognised URLs and methods. Everything else is
* reported, including responses the app threw deliberately (`internal: false`),
* so a 403 from an ownership check still shows up.
*
* The exception is a request carrying a same-origin `Referer`: a 404 reached
* from one of our own pages is a broken internal link, not a scanner, and stays
* visible in Sentry.
*/
export function shouldReportServerError(
error: unknown,
request: Request,
): boolean {
if (!isRouteErrorResponse(error)) return true;
if (!isInternalRouterError(error)) return true;
if (error.status < 400 || error.status > 499) return true;
return hasSameOriginReferer(request);
}

View file

@ -5,12 +5,6 @@ Sentry.init({
enabled: process.env.NODE_ENV === "production",
sendDefaultPii: true,
tracesSampleRate: 0,
ignoreErrors: [
/No route matches URL ".*\.css"/,
/No route matches URL ".*\.js"/,
/No route matches URL ".*\.(php|env|xml|aspx|asp|bak|sql|ini)"/i,
/No route matches URL ".*\/(wp-admin|wp-login|phpmyadmin|xmlrpc)"/i,
],
beforeSend(event) {
const msg = event.exception?.values?.[0]?.value ?? "";
// Drop React Flight protocol probe errors (e.g. $1:aa:aa in multipart body)

View file

@ -11,9 +11,11 @@ export const app = express();
app.use((_, __, next) => DatabaseContext.run(db, next));
// Block common bot probe paths before React Router (and Sentry) see them
// Block common bot probe paths before React Router (and Sentry) see them.
// `blog` is here only because scanners hammer /blog/wp/v2/* — drop it from this
// list if a real blog route is ever added.
const BOT_PROBE_RE =
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator)(\/|$)/i;
/\.(php|env|htaccess|aspx|asp|jsp|config|bak|sql|ini|swp|DS_Store)$|^\/(wp-admin|wp-login|phpmyadmin|xmlrpc|server-status|cgi-bin|shell|cmd|console|actuator|blog)(\/|$)/i;
app.use((req, res, next) => {
if (BOT_PROBE_RE.test(req.path)) {