W bazie jest zamówienie na -3 sztuki i rabat 120 proc. Interfejs takich wartości nie dopuszcza
Schema została w komponencie, a endpoint przyjmuje wszystko. Jak w kwadrans sprawdzić, co wygenerowany kod wpuszcza do bazy, i co poprawić przed oddaniem.
W tabeli orders leży rekord z ilością -3 i rabatem 120 proc. Ktoś go tam zapisał w czwartek po dwudziestej, a interfejs sklepu takich wartości nie dopuszcza: suwak rabatu kończy się na 30, pole ilości ma min="1", a schema w komponencie odrzuca jedno i drugie przed wysłaniem. Rekord i tak jest.
To nie błąd bazy ani awaria. To najczęstszy skutek podziału pracy, jaki wychodzi z generatora: sprawdzenie danych trafiło do komponentu, bo prompt dotyczył widoku, a trasa API powstała z osobnego promptu i przyjmuje to, co dostanie. Dopóki ktoś klika w przeglądarce, wszystko się zgadza. Pierwsze żądanie wysłane poza interfejsem pokazuje, gdzie naprawdę kończy się kontrola.
Jak to odtworzyć w dwie minuty na własnym projekcie
Nie trzeba czekać na dziwny rekord. Weź dowolny zapis, który w interfejsie ma ograniczenia, i wyślij go bez interfejsu. Z devtoolsów skopiuj żądanie jako cURL, podmień wartości na takie, których ekran nie pozwala wybrać, i wyślij ponownie:
curl -X POST https://sklep.example/api/orders \
-H 'content-type: application/json' \
-H "cookie: $SESSION" \
-d '{"qty":-3,"discount":120,"priceOverride":0.01,"status":"PAID"}'
Interesują Cię trzy rzeczy w odpowiedzi: kod HTTP, treść zapisanego rekordu i to, czy w rekordzie pojawiły się pola, których nie ma w interfejsie. Jeśli wraca 200 albo 201 z rekordem w środku, walidacja w komponencie była jedyną walidacją w tym projekcie. Jeśli wraca 422 albo 400 z komunikatem per pole, serwer ma własną schemę i możesz przejść do kolejnego endpointu.
Typowa para plików, która to powoduje, wygląda tak. Komponent:
// components/OrderForm.tsx
const schema = z.object({
qty: z.number().int().min(1).max(50),
discount: z.number().min(0).max(30),
});
const onSubmit = async (values: z.infer<typeof schema>) => {
await fetch("/api/orders", {
method: "POST",
body: JSON.stringify(values),
});
};
Trasa z drugiego promptu:
// app/api/orders/route.ts
export async function POST(req: Request) {
const body = await req.json();
const order = await db.order.create({ data: body });
return Response.json(order, { status: 201 });
}
Oba pliki są poprawne składniowo, przechodzą build i typecheck, a schema w pierwszym z nich wygląda jak dowód, że dane są sprawdzane. Nie są: z.infer daje typ na czas kompilacji, a nie bramkę w czasie wykonania po stronie serwera.
Dlaczego sprawdzenie zostaje w komponencie
Model optymalizuje to, co widać w zadaniu. Prompt o widok dostaje walidację, bo bez niej nie ma komunikatów pod polami. Prompt o endpoint dostaje najkrótszą ścieżkę do działającego zapisu, bo tak brzmiało zadanie. Nikt nie prosił o powtórzenie reguł dwa razy, więc nie zostały powtórzone.
Skala tego zjawiska jest policzona. W raporcie Veracode o bezpieczeństwie kodu z modeli generatywnych 45 proc. próbek nie przeszło testów bezpieczeństwa, wprowadzając do kodu podatności z listy OWASP Top 10 – przy ponad stu modelach i 80 zadaniach w Javie, Pythonie, C# i JavaScripcie. Najgorzej wypadło wstrzykiwanie treści do strony: ochrona przed XSS nie pojawiła się w 86 proc. próbek, w których była potrzebna, a różnice między językami są duże – 72 proc. porażek w Javie wobec 38 proc. w Pythonie. Nowsze modele piszą kod, który lepiej działa, ale nie bezpieczniejszy.
Drugie źródło liczy to samo szerzej. Cloud Security Alliance w nocie badawczej z kwietnia 2026 zbiera wyniki z zakresu od 45 do 70 proc. kodu z podatnościami, zależnie od metodyki i narzędzia skanującego, i wskazuje dwa dominujące wzorce: brak kontroli dostępu oraz dane uwierzytelniające wpisane w kod. W przytoczonym tam przeglądzie pięciu platform agentowych, które wygenerowały 15 aplikacji z 69 podatnościami, najczęstszą klasą krytyczną były błędy logiki autoryzacji.
Na pominiętą walidację wskazuje też praca akademicka. Autorzy badania 86 726 próbek kodu z siedmiu modeli w czterech językach kompilowanych, przyjętego na ISSRE 2026, odnotowują, że generowany kod często pomija podstawowe sprawdzenie danych wejściowych i kontrole bezpieczeństwa pamięci. Oddzielnie krąży zestawienie sześciu modeli na 89 promptach, w którym około 25 proc. kodu miało potwierdzoną podatność, a najczęstszym trafieniem było pobieranie adresu URL podanego przez użytkownika bez listy dozwolonych hostów; to materiał branżowy, nie recenzowany, więc traktuj go jako wskazanie kierunku, a nie pomiar.
Typ to nie zakres: składnia i sens wartości
Ściągawka OWASP o walidacji wejścia formułuje to jednym zdaniem: waliduj na serwerze nawet wtedy, gdy przeglądarka sprawdza te same pola, bo kontrola po stronie klienta jest do obejścia, a jej rolą jest natychmiastowa informacja dla użytkownika. Drugie zdanie z tej samej strony jest ważniejsze w praktyce: zamiana tekstu na liczbę całkowitą ustala typ, ale nie ustala dopuszczalnej wielkości. Sprawdzenie musi objąć składnię – typ, format – i sens, czyli czy wartość ma znaczenie dla tej operacji.
W kodzie z generatora brakuje najczęściej drugiej części. Number(body.qty) przechodzi dla -3 i dla 10 000. typeof body.discount === "number" przechodzi dla 120. Dlatego schema po stronie serwera potrzebuje granic i trybu odrzucania nieznanych kluczy:
// app/api/orders/route.ts
const Order = z.object({
sku: z.string().regex(/^[A-Z0-9-]{4,16}$/),
qty: z.coerce.number().int().min(1).max(50),
discount: z.coerce.number().min(0).max(30),
items: z.array(ItemSchema).min(1).max(20),
}).strict();
export async function POST(req: Request) {
const parsed = Order.safeParse(await req.json());
if (!parsed.success) {
return Response.json({ errors: parsed.error.flatten() }, { status: 422 });
}
const { sku, qty, discount, items } = parsed.data;
// ...
}
.strict() odrzuca pola, których nie ma w schemie – to jest różnica między odpowiedzią 422 i cichym zapisem status: "PAID" z żądania powyżej. Tablice dostają minimum i maksimum liczby elementów, bo bez tego jedno żądanie z dwudziestoma tysiącami pozycji jest poprawne formalnie.
Co sprawdza interfejs, a co musi sprawdzić serwer
| Pole | Kontrola w interfejsie | Wymagana kontrola na serwerze | Payload, który to obnaża |
|---|---|---|---|
| Ilość | min="1", krok 1 |
liczba całkowita, zakres 1-50, odrzucenie wartości ujemnej i zera | {"qty": -3} |
| Rabat | suwak 0-30 | zakres 0-30 plus reguła, kto może nadać ponad progiem | {"discount": 120} |
| Cena | pole tylko do odczytu | cena liczona na serwerze z katalogu, pole z żądania ignorowane | {"priceOverride": 0.01} |
| Status | brak w interfejsie | zbiór dozwolonych wartości i dozwolone przejścia między nimi | {"status": "PAID"} |
| Identyfikator rekordu | pole ukryte | przypisanie rekordu do konta z sesji, nie do wartości z żądania | {"id": "inny-rekord"} |
| Adres pliku do pobrania | lista wyboru | lista dozwolonych hostów, odrzucenie adresów wewnętrznych | {"url": "http://169.254.169.254/"} |
| Załącznik | filtr accept |
typ wykryty z zawartości, limit rozmiaru, nazwa budowana od nowa | plik .svg podany jako image/png |
Kolumna czwarta jest planem pracy na kwadrans. Każdy wiersz to jedno żądanie do wysłania przed oddaniem projektu; kolejność nie ma znaczenia, liczba trafień tak.
Server action to publiczny endpoint, a rozpakowane body to dodatkowe pola
W projektach z App Routerem kontrola rozjeżdża się jeszcze w jednym miejscu. Każda funkcja z dyrektywą 'use server' jest osiągalna jako żądanie POST, niezależnie od tego, czy komponent, który ją wywołuje, jest za logowaniem. Osłona na poziomie strony albo w middleware nie jest osłoną tej funkcji – uwierzytelnienie i autoryzacja muszą być w jej ciele. Dodatkowym argumentem jest CVE-2026-64643, czyli ujawnianie identyfikatorów server actions w publicznych plikach klienckich, ocenione na 5,3 w skali CVSS: dotyczy wersji od 13.0.0 do 15.5.21 i od 16.0.0 do 16.2.11, a poprawki są w 15.5.21 i 16.2.11. Sama podatność jest tylko rozpoznaniem, ale rozpoznanie wystarcza, gdy funkcja nie sprawdza, kto ją wywołał.
Drugi wzorzec to przekazanie całego ciała żądania do zapisu. db.order.update({ where: { id }, data: body }) jest krótkie i działa na demo, ale zapisuje każde pole, które przyjdzie – w tym takie, których nie ma w interfejsie. Wersja, która nie wpuszcza nadmiaru, wymienia pola jawnie i wiąże rekord z sesją w warunku:
const { qty, discount } = parsed.data;
const updated = await db.order.updateMany({
where: { id: params.id, userId: session.user.id, status: "DRAFT" },
data: { qty, discount },
});
if (updated.count === 0) {
return Response.json({ error: "not_found" }, { status: 404 });
}
Warunek userId w where jest tu ważniejszy od samej listy pól: zapis nie dotknie rekordu, który należy do kogoś innego, nawet jeśli identyfikator z żądania jest poprawny. Odpowiedź 404 zamiast 403 nie zdradza przy okazji, że taki rekord istnieje.
Walidacja nie zastąpi kontroli uprawnień do rekordu
Schema odpowiada na pytanie, czy wartość jest dopuszczalna. Nie odpowiada na pytanie, czy ten użytkownik może ją zapisać w tym rekordzie. To osobna warstwa i najdroższa w skutkach: w nocie CSA najgłośniejszym przykładem jest brak reguł dostępu do wierszy w bazie przy aplikacjach z jednego generatora – 303 podatne endpointy w 170 aplikacjach ze skanu obejmującego 1 645 projektów. Dane były poprawne składniowo. Czytał je, kto chciał.
Praktyczny wniosek dla projektu, w którym model generował warstwę dostępu do danych: reguły dostępu ustawia się w bazie albo w jednym miejscu po stronie serwera, a nie w zapytaniach rozsypanych po trasach. Ten sam mechanizm obowiązuje narzędzia automatyczne podłączone do firmowych systemów – problem granic uprawnień wygląda identycznie, kiedy do bazy sięga nie przeglądarka, a integracja, co dobrze pokazuje tekst o tym, jak agent podłączony do CRM przez MCP widzi więcej danych, niż powinien.
Kolejność sprawdzenia przed oddaniem i co wpisać w protokół
Lista poniżej zajmuje od dwudziestu minut do godziny na projekt średniej wielkości i ma sens tylko wykonana w tej kolejności – pierwsze dwa punkty decydują, czy pozostałe mają znaczenie.
- Wypisz wszystkie trasy API i funkcje z
'use server', które zapisują albo usuwają dane. To lista do przejścia, nie próbka. - Dla każdej sprawdź, czy w ciele funkcji jest uwierzytelnienie i sprawdzenie uprawnień do konkretnego rekordu. Osłona w middleware się nie liczy.
- Dla każdej sprawdź, czy dane z żądania przechodzą przez schemę z granicami i trybem odrzucania nieznanych kluczy, a nie tylko przez rzutowanie typu.
- Wyślij siedem żądań z ostatniej kolumny tabeli. Zapisz kody odpowiedzi – to jest dowód w protokole odbioru.
- Sprawdź, czy cena, status i identyfikator właściciela są wyliczane na serwerze, a nie przyjmowane z żądania.
- Sprawdź wersję frameworka wobec opublikowanych poprawek bezpieczeństwa i zapisz numer wersji, na której projekt był oddawany.
- Ustaw limit liczby żądań na trasach zapisu i limit rozmiaru ciała żądania. Brak limitu to osobna pozycja, nie szczegół.
Zostaje pytanie, kto za to płaci. Kwadrans przy odbiorze mieści się w wycenie projektu. Kontrola przy każdym kolejnym wdrożeniu, aktualizacje wersji frameworka i reakcja na nowe podatności już nie – to praca cykliczna i rozliczana cyklicznie albo nierobiona wcale. Mechanika takiego przejścia z pojedynczych projektów na pracę abonamentową opisaną w materiale o biznesie na żądanie jest tu bardziej na miejscu niż kolejna pozycja w kosztorysie.
Ta lista jest procedurą, więc obowiązują ją reguły każdej procedury: ma mieć właściciela, wynik do zapisania i miejsce, w którym żyje między projektami. Jeśli ma działać w zespole, a nie tylko w Twojej głowie, pomoże tekst o tym, jak opisać proces, żeby ktoś inny faktycznie go wykonał.
Decyzja na dziś jest węższa i nie wymaga niczyjej zgody: weź jeden projekt, który jest u klienta, wyślij do niego trzy żądania z tabeli i zapisz kody odpowiedzi. Jeśli wrócą dwieście jeden, masz zadanie na najbliższe dwie godziny i konkretny powód, żeby o nim napisać klientowi. Jeśli wrócą czterysta dwadzieścia dwa, masz pierwszy wiersz protokołu odbioru dla następnego projektu.