Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion .github/workflows/e2e.yml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,17 @@ jobs:
MINIMAL_SEED: true
SKIP_VALIDATION: true

- run: pnpm --filter core exec playwright install --with-deps
# the runner's apt mirror list tries azure.archive.ubuntu.com first, which sometimes
# times out on every request before falling back. skip straight to the fallback.
- name: Skip azure apt mirror
run: |
if [ -f /etc/apt/apt-mirrors.txt ] && grep -v azure.archive.ubuntu.com /etc/apt/apt-mirrors.txt | grep -q .; then
sudo sed -i '/azure\.archive\.ubuntu\.com/d' /etc/apt/apt-mirrors.txt
fi
cat /etc/apt/apt-mirrors.txt || true

# core only runs chromium; add firefox here if context-editor is re-enabled
- run: pnpm --filter core exec playwright install --with-deps chromium

- name: Start up core etc
run: pnpm integration:setup
Expand Down
20 changes: 19 additions & 1 deletion core/instrumentation.onRequestError.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,21 @@
import type { InstrumentationOnRequestError } from "next/dist/server/instrumentation/types"

import * as Sentry from "@sentry/nextjs"

export const onRequestError = Sentry.captureRequestError
import { classifyServerActionProbe, serverActionProbeContext } from "./lib/server/serverActionProbe"

export const onRequestError: InstrumentationOnRequestError = (error, request, context) => {
const probe = classifyServerActionProbe(error, request)
if (!probe) {
return Sentry.captureRequestError(error, request, context)
}

// group each kind into a single warning-level issue, filterable with probe:<kind>
Sentry.withScope((scope) => {
scope.setLevel("warning")
scope.setFingerprint([probe.kind])
scope.setTag("probe", probe.kind)
scope.setContext("probe", serverActionProbeContext(request))
Sentry.captureRequestError(probe, request, context)
})
}
67 changes: 67 additions & 0 deletions core/lib/server/serverActionProbe.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import { describe, expect, test } from "vitest"

import { classifyServerActionProbe } from "./serverActionProbe"

const parseError = () => {
try {
JSON.parse('{"then":"$1"x}')
} catch (error) {
return error
}
throw new Error("expected JSON.parse to throw")
}

const actionRequest = (headers: Record<string, string> = {}) => ({
path: "/login",
method: "POST",
headers: { "next-action": "x", "content-type": "text/plain;charset=UTF-8", ...headers },
})

describe("classifyServerActionProbe", () => {
test("flags an undecodable Server Action payload", () => {
const probe = classifyServerActionProbe(parseError(), actionRequest())

expect(probe?.kind).toBe("malformed-server-action")
expect(probe?.name).toBe("MalformedServerAction")
expect(probe?.message).toContain("POST /login")
expect(probe?.cause).toBeInstanceOf(SyntaxError)
})

test("flags a multipart form post without a next-action header", () => {
const probe = classifyServerActionProbe(parseError(), {
path: "/login",
method: "POST",
headers: { "content-type": "multipart/form-data; boundary=x" },
})

expect(probe?.kind).toBe("malformed-server-action")
})

test("flags an unknown Server Action ID", () => {
const probe = classifyServerActionProbe(
new Error(
"Failed to find Server Action. This request might be from an older or newer deployment."
),
actionRequest()
)

expect(probe?.kind).toBe("unknown-server-action")
})

test("leaves other errors in Server Actions alone", () => {
expect(classifyServerActionProbe(new Error("database is down"), actionRequest())).toBeNull()
})

test("leaves parse errors outside Server Actions alone", () => {
expect(
classifyServerActionProbe(parseError(), { path: "/login", method: "GET", headers: {} })
).toBeNull()
expect(
classifyServerActionProbe(parseError(), {
path: "/api/v0/pubs",
method: "POST",
headers: { "content-type": "application/json" },
})
).toBeNull()
})
})
81 changes: 81 additions & 0 deletions core/lib/server/serverActionProbe.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
type RequestHeaders = NodeJS.Dict<string | string[]>

export type ServerActionProbeKind = "malformed-server-action" | "unknown-server-action"

/**
* Stands in for the original error in Sentry, so these requests group into
* one clearly named issue per kind instead of a pile of raw parse errors.
* The original error stays attached as `cause`.
*/
export class ServerActionProbeError extends Error {
readonly kind: ServerActionProbeKind

constructor(kind: ServerActionProbeKind, message: string, cause: unknown) {
super(message, { cause })
this.name =
kind === "malformed-server-action" ? "MalformedServerAction" : "UnknownServerAction"
this.kind = kind
}
}

const header = (headers: RequestHeaders, name: string) => {
const value = headers[name]
return Array.isArray(value) ? value[0] : value
}

/**
* Next treats any POST with a `next-action` header, or a multipart / urlencoded
* form body, as a possible Server Action call.
*/
const isServerActionRequest = (request: { method: string; headers: RequestHeaders }) => {
if (request.method !== "POST") {
return false
}
const contentType = header(request.headers, "content-type") ?? ""
return (
header(request.headers, "next-action") !== undefined ||
contentType.startsWith("multipart/form-data") ||
contentType.startsWith("application/x-www-form-urlencoded")
)
}

/**
* Recognizes Server Action requests that failed before any of our code ran:
* the payload couldn't be decoded, or the action ID doesn't exist. Malformed
* payloads are almost always vulnerability scanners (React2Shell-style probes);
* unknown IDs are scanners or tabs left open across a deploy. Returns null for
* everything else, which should be reported as usual.
*/
export const classifyServerActionProbe = (
error: unknown,
request: { path: string; method: string; headers: RequestHeaders }
): ServerActionProbeError | null => {
if (!(error instanceof Error) || !isServerActionRequest(request)) {
return null
}

if (error.message.startsWith("Failed to find Server Action")) {
return new ServerActionProbeError(
"unknown-server-action",
`POST ${request.path} called a Server Action that doesn't exist (stale tab after a deploy, or a scanner)`,
error
)
}

if (error instanceof SyntaxError) {
return new ServerActionProbeError(
"malformed-server-action",
`POST ${request.path} sent a Server Action payload that couldn't be decoded (likely a vulnerability scanner)`,
error
)
}

return null
}

export const serverActionProbeContext = (request: { headers: RequestHeaders }) => ({
nextAction: header(request.headers, "next-action"),
contentType: header(request.headers, "content-type"),
userAgent: header(request.headers, "user-agent"),
forwardedFor: header(request.headers, "x-forwarded-for"),
})
19 changes: 5 additions & 14 deletions core/next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,21 +30,12 @@ const nextConfig: NextConfig = {
*/
cacheHandler: new URL("./cache-handler.mjs", import.meta.url).pathname,
cacheMaxMemorySize: 0,
/**
* We don't use next/image. Turning optimization off makes /_next/image
* 404 instead of exposing the optimizer (and sharp) to the internet.
*/
images: {
remotePatterns: [
{
protocol: "https",
hostname: "avatars.githubusercontent.com",
port: "",
pathname: "/u/**",
},
{
protocol: "https",
hostname: "cloudflare-ipfs.com",
port: "",
pathname: "/ipfs/**",
},
],
unoptimized: true,
},
turbopack: {
root: new URL("./..", import.meta.url).pathname,
Expand Down
5 changes: 3 additions & 2 deletions docker-compose.base.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
services:
minio:
image: minio/minio:RELEASE.2025-04-22T22-12-26Z
# minio/minio is gone from docker hub and quay; pgsty/minio is the maintained community fork
image: pgsty/minio:RELEASE.2026-08-04T00-00-00Z
healthcheck:
test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/ready" ]
interval: 1m30s
Expand All @@ -16,7 +17,7 @@ services:
- "9001:9001" # Console

minio-init:
image: minio/mc:latest
image: pgsty/mc:RELEASE.2026-10-18T00-00-00Z
# basically what this does:
# - create the public assets bucket
# - allow public downloads for said bucket
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,8 @@
"@radix-ui/react-dismissable-layer": "^1.0.5",
"@radix-ui/react-focus-scope": "^1.0.4",
"@react-email/render@1>prettier": "^3.6.2",
"react-email@3>next": "^15.5.0",
"react-email@3>next": "^15.5.28",
"sharp": "^0.35.5",
"@opentelemetry/instrumentation@0>import-in-the-middle": "1.14.2",
"react-email@3>glob": "^10.5.0",
"tailwindcss@3>sucrase": "^3.35.1"
Expand Down
Loading
Loading