špatný mentální model
Co skoro každý tým, po kterém jsme zdědili Next.js codebase, pokládá špatně: Server Actions vypadají jako obyčejné volání funkce. Napíšete async funkci, nahoru plácnete 'use server', naimportujete ji do komponenty, zavoláte z onSubmit handleru a všechno funguje. Vypadá to, jako byste volali lokální kód. Nevoláte. Když funkci označíte pomocí 'use server', Next.js ji zkompiluje do POST endpointu se stabilním, deterministickým ID a zapojí klienta tak, že volání té funkce ve skutečnosti odpálí HTTP request na /your-page se speciální hlavičkou a serializovaným payloadem. Na ten endpoint se dostane kdokoli s curlem a třiceti vteřinami čtení vašeho bundle. To, že je funkce naimportovaná do konkrétní komponenty, nijak neomezuje, kdo na ni může sáhnout. Hranice komponenty je UI koncept. Action je síťová hranice, a síťové hranice se testují na průraz. Viděli jsme produkční aplikace, kde byla celá admin mutace postavená na Server Actions chráněných ničím jiným než tím, že se non-adminům nerenderoval admin link. To, že se link nerenderuje, nezastaví request ani trochu. Jakmile vám dojde, že každá 'use server' funkce je stejně vystavená jako route handler v app/api, zbytek tohohle článku je jen checklist, který byste stejně aplikovali na jakýkoli API endpoint, protože přesně tím tyhle věci jsou.
auth na hranici action, pokaždé
První řádek každé mutující action má zjistit, kdo ji volá a jestli na to má právo. Ne na stránce, ne v layoutu, ne jen v middleware. V samotné action. Middleware v Next.js běží na edge a je vážně užitečná na hrubé redirecty, jenže lidi se na ni spoléhají kvůli autorizaci a pak se spálí, protože konfigurace matcheru se dá snadno pokazit nenápadným způsobem a POST ze Server Action neprojde vždycky tou cestou, kterou předpokládáte. My bereme action samotnou jako zdroj pravdy.
Konkrétně to znamená něco takového nahoře ve funkci, ještě než se čehokoli dotknete:
1'use server'23export async function deleteInvoice(formData: FormData) {4 const session = await auth()5 if (!session?.user) throw new Error('unauthorized')67 const invoiceId = formData.get('id')8 // kontrola vlastnictví, ne jen "je přihlášený"9 const invoice = await db.invoice.findUnique({ where: { id: invoiceId } })10 if (invoice?.orgId !== session.user.orgId) throw new Error('forbidden')11 // ... pokračuj12}
To, co všichni vynechávají, je kontrola vlastnictví. Ověřit, že je uživatel přihlášený, je úplný základ. Skutečné bugy jsou IDOR bugy, kdy uživatel A pošle ID faktury uživatele B a vaše action ji vesele smaže, protože jediná kontrola bylo, že session?.user je truthy. Každá action, která přijímá ID od klienta, musí dokázat, že aktuální session má na ten konkrétní resource opravdu práva. My tohle balíme do malého helperu, takže je to jedno volání místo pěti řádků obřadu, a lintujeme na actions, které sáhnou do databáze bez toho, aby přes něj prošly, protože jakmile je to volitelné, někdo na to zapomene.
validujte dřív, než sáhnete na databázi
Server Actions dostávají FormData nebo serializované argumenty a obojí je plně pod kontrolou útočníka. Klient může poslat jakýkoli tvar chce. Někdo může vaši action zavolat s quantity -9999, s role superadmin, s e-mailem, který je ve skutečnosti 40kb string určený k rozbití vašeho loggingu, nebo s objektem, který má klíče navíc, jaké byste nikdy nečekali. Když to prostrčíte rovnou do Prismy nebo raw query, důvěřujete síti.
My vstup každé action proženeme přes Zod schéma ještě před jediným voláním databáze. Zod 4 parsuje dost rychle na to, že jeho cena je proti round tripu na DB šum, a výsledek z .safeParse vám dá strukturované chyby, které můžete vrátit do formuláře bez toho, abyste vyzradili vnitřnosti.
1const schema = z.object({2 invoiceId: z.string().uuid(),3 amount: z.number().int().positive().max(1_000_000),4 note: z.string().max(500).optional(),5})67export async function updateInvoice(input: unknown) {8 const parsed = schema.safeParse(input)9 if (!parsed.success) {10 return { error: 'invalid input' }11 }12 const { invoiceId, amount, note } = parsed.data13 // teď je bezpečné pokračovat14}
Všimněte si, že action bere input: unknown. To je záměr. Když parametr otypujete jako svůj happy-path interface, TypeScript vás přesvědčí, že data už mají správný tvar, a typy TypeScriptu se za běhu vypaří, takže dostanete falešný pocit bezpečí. Otypujte to jako unknown, vynuťte parse a kompilátor vás donutí ošetřit případ selhání. Tenhle jeden návyk chytí obrovskou třídu bugů, od mass-assignmentu po type confusion, a stojí vás asi čtyři řádky na action.
rate limiting, který vám nikdo nedal zadarmo
Route handlery, Server Actions, to všechno přichází s nulovým rate limitingem out of the box. Pokud máte signIn action nebo sendPasswordReset action nebo cokoli, co spouští e-mail nebo sahá na externí API, může to jediný klient bušit tisíckrát za vteřinu, a zjistíte to, až vám vyskočí účet od Postmarku nebo se vyčerpá connection pool databáze.
My děláme rate limiting per action, per identita v Redisu. Identita je user ID, když existuje session, a IP, když ne, protože neautentizované actions jako login jsou přesně ty, které se zneužívají. Pro většinu případů stačí sliding window counter a je levný.
1import { Ratelimit } from '@upstash/ratelimit'2import { Redis } from '@upstash/redis'34const limiter = new Ratelimit({5 redis: Redis.fromEnv(),6 limiter: Ratelimit.slidingWindow(5, '60 s'),7 prefix: 'action:reset-password',8})910export async function resetPassword(input: unknown) {11 const ip = (await headers()).get('x-forwarded-for') ?? 'unknown'12 const { success } = await limiter.limit(ip)13 if (!success) return { error: 'too many requests, slow down' }14 // ...15}
Dejte každé citlivé action vlastní prefix, aby náraz na login nesežral budget třeba pro postování komentářů. Kompromis serverless Redisu od Upst4she je jeden síťový hop na kontrolu, což je na Vercel funkcích ve stejném regionu obvykle pár milisekund, a pokud už si vlastní Redis provozujete kvůli sessions nebo cache, můžete limiter nasměrovat na něj a extra službu úplně vynechat. Dělali jsme obojí. U pipeline na zpracování dokumentů, kterou jsme nedávno stavěli, seděl rate limit přímo před action, která spouštěla OCR joby, protože tahle action se rozjížděla do fronty a jeden zneužívající klient mohl zahltit celý systém.
audit logging bez round tripů
Jakmile máte auth, validaci a rate limiting, poslední kousek, který lidi chtějí, je vědět, kdo co udělal, hlavně v zákaznických portálech a interních admin nástrojích, kde mutace může pohnout penězi nebo smazat záznamy. Naivní přístup je na konci každé action awaitnout insert do tabulky audit_log, což přidá synchronní round trip na databázi ke každé mutaci a potichu vám aplikaci zpomalí.
My to neawaitujeme. Zápis auditu jde na fire-and-forget cestu, buď lehká fronta, nebo batchovaný writer, který se flushuje v intervalu, takže se action vrátí uživateli okamžitě a log dorazí o chvilku později. Pokud jste na Node runtime, můžete použít after() z next/server a spustit práci až po odeslání response:
1import { after } from 'next/server'23export async function deleteInvoice(input: unknown) {4 // ... auth, validace, smazání ...5 after(async () => {6 await logAudit({7 actor: session.user.id,8 action: 'invoice.delete',9 target: parsed.data.invoiceId,10 at: new Date(),11 })12 })13 return { ok: true }14}
Záznam v logu má zachytit aktéra, jméno akce, cílový resource a timestamp, a nikdy nemá obsahovat raw payloady, které by mohly mít secrets nebo osobní údaje, jaké nechcete mít věčně sedět v tabulce. Ukládejte identifikátory, ne celý request. Když se něco v noci ve dvě pokazí a potřebujete zrekonstruovat, kdo smazal záznamy zákazníka, tahle tabulka je rozdíl mezi odpovědí za deset vteřin a forenzní archeologií napříč aplikačními logy, které už možná dávno rotovaly.
ať je bezpečná cesta jediná cesta
Dělat tohle všechno ručně nahoře v každé action funguje přesně do chvíle, kdy codebase přeroste pár actions a někdo ve spěchu shipne jednu, která přeskočí krok. Řešení je přestat se spoléhat na disciplínu. My celý pattern zabalíme do factory, takže definování action vás designem donutí projít přes auth, validaci a rate limiting, a jednoduše nejde napsat nezabezpečenou tak, aby se zkompilovala.
1export const updateInvoice = createAction({2 schema: updateInvoiceSchema,3 rateLimit: { max: 10, window: '60 s' },4 requireAuth: true,5 handler: async ({ input, session }) => {6 // input je naparsovaný a otypovaný, session je zaručená7 },8})
Uvnitř createAction proběhne kontrola session, rate limiter klíčovaný na uživatele nebo IP, Zod parse a audit hook, a teprve pak se zavolá handler s čistými, otypovanými argumenty. Handler se nemůže rozjet, dokud guardy neprojdou. Když onboardujeme nového inženýra, nemusí si pamatovat checklist, prostě nedokáže napsat action bez něj. O to celé jde. Bezpečnost, která závisí na tom, že si každý vývojář v pátek odpoledne vzpomene udělat čtyři věci, je bezpečnost, která selže, a čím dřív ji vtlačíte do tvaru svého toolingu, tím míň nočních budíčků dostanete. Pokud jste ve sprintu shipnuli hromadu Server Actions a čtete tohle s tichým pocitem, jestli jste náhodou nenechali otevřené dveře, věnujte jedno ráno grepnutí 'use server' a projděte každou proti tomuhle seznamu. Většinou najdete aspoň jednu, která je dokořán.
