brackt/app/lib/error-reporting.ts

88 lines
3.5 KiB
TypeScript
Raw Normal View History

/**
* 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";
/**
* Statuses React Router uses to say "nothing here matched this request":
* 404 when no route matches the URL, 405 when the route has no `action` or the
* method is invalid. Its other internal statuses (400 "did not provide a
* `loader`", 403 "Route does not match URL") describe a misconfigured route
* rather than an unrecognised request, so those keep reporting.
*/
const UNMATCHED_REQUEST_STATUSES = new Set([404, 405]);
/**
* Static assets 404 in bulk for reasons that are never actionable: scanners
* guessing filenames, and clients running stale HTML that still references the
* previous deploy's hashed bundles.
*/
const ASSET_EXT_RE =
/\.(css|js|mjs|map|png|jpe?g|gif|svg|webp|avif|ico|woff2?|ttf|eot)$/i;
/** 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 site.
*
* Compares host rather than origin on purpose. Production terminates TLS
* upstream and serves plain HTTP in the container, so `request.url` which
* `@react-router/express` builds from `req.protocol` says `http` while the
* browser sends an `https` referer. Comparing full origins would therefore
* never match in production. (`app/routes/leagues/$leagueId.server.ts` works
* around the same mismatch for invite URLs.) Protocol tells us nothing about
* whether the link was ours; host does.
*/
function hasSameHostReferer(request: Request): boolean {
const referer = request.headers.get("referer");
if (!referer) return false;
try {
return new URL(referer).host === new URL(request.url).host;
} 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 the 404s and 405s React Router generated for a request that matched
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
* other internal statuses, and 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-host `Referer`: a 404 reached from
* one of our own pages is a broken internal link, not a scanner, and stays
* visible in Sentry. Asset paths are excluded from that exception a stale
* client requesting last deploy's bundle sends a same-host referer too, and
* would otherwise spike Sentry on every release.
*/
export function shouldReportServerError(
error: unknown,
request: Request,
): boolean {
if (!isRouteErrorResponse(error)) return true;
if (!isInternalRouterError(error)) return true;
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
let pathname: string;
try {
pathname = new URL(request.url).pathname;
} catch {
pathname = "";
}
if (ASSET_EXT_RE.test(pathname)) return false;
return hasSameHostReferer(request);
}