This looks like a function call:
<form action={deleteInvoice}>
<button type="submit">Delete</button>
</form>
It is a network request to an endpoint, and the endpoint exists whether or not anyone renders that button.
What a "use server" export actually is
Every exported function in a "use server" file is compiled into a callable HTTP endpoint with a generated identifier. The identifier ships to the client, because the client needs it to make the call. Anyone can then invoke it directly with a crafted request.
Three consequences.
The permission check in your UI protects nothing. Hiding the delete button from non-admins changes what a browser renders and does not remove the endpoint. It is the same lesson as hiding a link to /admin, in a shape that looks much more like local code.
The function signature is a suggestion. TypeScript types are erased at runtime, so a parameter typed id: number will happily receive { $gt: "" }, an array, or a nine megabyte string.
There is no middleware in front of it by default. Route handlers at least look like routes, so people remember to guard them. A Server Action looks like a helper you imported.
So this is an unauthenticated delete API:
"use server";
export async function deleteInvoice(id: number) {
await db.invoice.delete({ where: { id } });
}
And this is the fix, in the order the steps have to happen:
"use server";
export async function deleteInvoice(raw: unknown) {
const session = await auth();
if (!session) throw new Error("Unauthenticated");
const { id } = deleteSchema.parse(raw);
const invoice = await db.invoice.findUnique({ where: { id } });
if (invoice?.ownerId !== session.user.id) throw new Error("Forbidden");
await db.invoice.delete({ where: { id } });
}
Authenticate, parse, then authorize the specific record. Checking that someone is logged in and checking that this invoice is theirs are different checks, and the gap between them is where most real incidents live.
Four more that static analysis catches
Secrets in the client bundle. Anything prefixed NEXT_PUBLIC_ is inlined into the browser bundle at build time, permanently, for every visitor. A variable named like a secret with that prefix is a disclosure that has already happened to everyone who loaded the page. Rotating it is the fix; removing the prefix stops the next one.
Open redirects from the query string. The interesting case is not ?next=https://evil.example.com, which people remember to block. It is this:
?next=//evil.example.com
That is protocol relative. It starts with a slash, so a naive startsWith("/") check passes it, and the browser treats it as an absolute URL on another origin. Accept same origin relative paths only, and test the double slash specifically.
SSRF from the query string. A server component, route handler or Server Action fetching a URL the user supplied is reaching out from somewhere the browser cannot go: internal services, and cloud metadata endpoints that will hand over credentials to anything that asks. Allowlist the host, resolve the name, reject private ranges.
dangerouslySetInnerHTML with anything that moved through user input.
One honest limitation
The Server Action rules in my ruleset are TypeScript only.
Not by preference. The exclusions those rules depend on are expressed as block body patterns, and Semgrep's JavaScript parser rejects them, which makes the rule error out rather than match nothing. A rule that fails silently covers nothing while looking like coverage.
So Server Actions written in plain .js are not scanned, and that is written in a comment at the top of the rule file.
Running it
npx --yes semgrep --config https://raw.githubusercontent.com/catidegla/stacksec/main/rules .
19 rules across Laravel and Next.js, a GitHub Action if you want it in CI, and every rule has to pass a corpus of code it must stay silent on. stacksec.
Top comments (0)