claude/gifted-allen-npeql8 #150
2 changed files with 128 additions and 12 deletions
|
|
@ -150,3 +150,81 @@ describe("shouldReportServerError against real React Router errors", () => {
|
||||||
expect(shouldReportServerError(error, req)).toBe(false);
|
expect(shouldReportServerError(error, req)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("static asset 404s", () => {
|
||||||
|
it("drops a stale hashed bundle even with a same-host referer", () => {
|
||||||
|
// Every deploy leaves clients requesting the previous build's assets.
|
||||||
|
const req = request("/assets/index-OLDHASH.js", `${ORIGIN}/leagues`);
|
||||||
|
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"/assets/app-x1.css",
|
||||||
|
"/fonts/inter.woff2",
|
||||||
|
"/images/logo.png",
|
||||||
|
"/favicon.ico",
|
||||||
|
])("drops a 404 for %s", (path) => {
|
||||||
|
expect(
|
||||||
|
shouldReportServerError(
|
||||||
|
routeError(404, true),
|
||||||
|
request(path, `${ORIGIN}/`),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still follows the referer rule for a non-asset path containing a dot", () => {
|
||||||
|
expect(
|
||||||
|
shouldReportServerError(
|
||||||
|
routeError(404, true),
|
||||||
|
request("/leagues/v1.2", `${ORIGIN}/leagues`),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
expect(
|
||||||
|
shouldReportServerError(routeError(404, true), request("/leagues/v1.2")),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("React Router internal statuses that are not 404/405", () => {
|
||||||
|
it("reports an internal 400 (route is missing a loader)", () => {
|
||||||
|
expect(
|
||||||
|
shouldReportServerError(
|
||||||
|
routeError(400, true, "Bad Request"),
|
||||||
|
request("/leagues"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an internal 403 (route does not match URL)", () => {
|
||||||
|
expect(
|
||||||
|
shouldReportServerError(
|
||||||
|
routeError(403, true, "Forbidden"),
|
||||||
|
request("/leagues"),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("production shape: TLS terminated upstream", () => {
|
||||||
|
it("reports a 404 linked from our own site when the proxy strips https", () => {
|
||||||
|
// Express builds request.url from req.protocol, which is `http` inside the
|
||||||
|
// container. Real browsers send an https referer. Comparing full origins
|
||||||
|
// would never match, silencing every broken internal link.
|
||||||
|
const req = new Request("http://brackt.com/leagues/gone", {
|
||||||
|
headers: { referer: "https://brackt.com/leagues" },
|
||||||
|
});
|
||||||
|
expect(shouldReportServerError(routeError(404, true), req)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still drops a cold scanner hit under that same shape", () => {
|
||||||
|
const req = new Request("http://brackt.com/blog/wp/v2/posts/999999");
|
||||||
|
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still drops a 404 linked from another site under that same shape", () => {
|
||||||
|
const req = new Request("http://brackt.com/nope", {
|
||||||
|
headers: { referer: "https://evil.example/" },
|
||||||
|
});
|
||||||
|
expect(shouldReportServerError(routeError(404, true), req)).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,44 @@
|
||||||
*/
|
*/
|
||||||
import { isRouteErrorResponse } from "react-router";
|
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. */
|
/** React Router stamps `internal: true` on the errors it generates itself. */
|
||||||
function isInternalRouterError(error: unknown): boolean {
|
function isInternalRouterError(error: unknown): boolean {
|
||||||
return (error as { internal?: unknown }).internal === true;
|
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 {
|
* 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");
|
const referer = request.headers.get("referer");
|
||||||
if (!referer) return false;
|
if (!referer) return false;
|
||||||
try {
|
try {
|
||||||
return new URL(referer).origin === new URL(request.url).origin;
|
return new URL(referer).host === new URL(request.url).host;
|
||||||
} catch {
|
} catch {
|
||||||
// Scanners send garbage in this header; a referer we can't parse isn't ours.
|
// Scanners send garbage in this header; a referer we can't parse isn't ours.
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -29,14 +56,16 @@ function hasSameOriginReferer(request: Request): boolean {
|
||||||
/**
|
/**
|
||||||
* Whether `error` should be reported to Sentry.
|
* Whether `error` should be reported to Sentry.
|
||||||
*
|
*
|
||||||
* Drops only the 4xx responses React Router generated for a request that
|
* Drops the 404s and 405s React Router generated for a request that matched
|
||||||
* matched nothing — that is, unrecognised URLs and methods. Everything else is
|
* nothing. Everything else is reported: real exceptions, 5xx, React Router's
|
||||||
* reported, including responses the app threw deliberately (`internal: false`),
|
* other internal statuses, and responses the app threw deliberately
|
||||||
* so a 403 from an ownership check still shows up.
|
* (`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
|
* The exception is a request carrying a same-host `Referer`: a 404 reached from
|
||||||
* from one of our own pages is a broken internal link, not a scanner, and stays
|
* one of our own pages is a broken internal link, not a scanner, and stays
|
||||||
* visible in Sentry.
|
* 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(
|
export function shouldReportServerError(
|
||||||
error: unknown,
|
error: unknown,
|
||||||
|
|
@ -44,6 +73,15 @@ export function shouldReportServerError(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (!isRouteErrorResponse(error)) return true;
|
if (!isRouteErrorResponse(error)) return true;
|
||||||
if (!isInternalRouterError(error)) return true;
|
if (!isInternalRouterError(error)) return true;
|
||||||
if (error.status < 400 || error.status > 499) return true;
|
if (!UNMATCHED_REQUEST_STATUSES.has(error.status)) return true;
|
||||||
return hasSameOriginReferer(request);
|
|
||||||
|
let pathname: string;
|
||||||
|
try {
|
||||||
|
pathname = new URL(request.url).pathname;
|
||||||
|
} catch {
|
||||||
|
pathname = "";
|
||||||
|
}
|
||||||
|
if (ASSET_EXT_RE.test(pathname)) return false;
|
||||||
|
|
||||||
|
return hasSameHostReferer(request);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue