Skan katalogu dist czysty, a token jest w źródle strony. Przeszedł granicę jako prop komponentu klienckiego
Prop przekazany do komponentu "use client" jedzie do przeglądarki bez żadnego prefiksu. Jak to sprawdzić na wdrożonej stronie i czym to zatrzymać w kodzie.
W odpowiedzi HTML strony produkcyjnej, w jednym z doklejonych na końcu znaczników script, siedzi pole apiToken z czterdziestoznakową wartością. W katalogu dist tej wartości nie ma: skan wyniku builda kończy się czysto, skan repozytorium też. Zmienna, z której token pochodzi, nie ma żadnego publicznego prefiksu — nazywa się PAYMENTS_API_TOKEN i jest czytana w komponencie serwerowym.
Nic się tu nie zepsuło po drodze. Token przeszedł granicę serwer–klient jako prop i pojechał do przeglądarki tą samą drogą, którą jedzie każda inna wartość przekazana do komponentu oznaczonego "use client". Narzędzia szukające sekretów w plikach tego nie zobaczą, bo wartości nie ma w żadnym pliku — jest w odpowiedzi serwera.
Granica nie przebiega po nazwie zmiennej, tylko po propsach
Oficjalny wpis Vercela o bezpieczeństwie w App Routerze stawia to jednym zdaniem: podstawowym sposobem przenoszenia danych jest protokół React Server Components, co dzieje się automatycznie przy przekazywaniu propsów do komponentów klienckich. Automatycznie — czyli bez żadnej deklaracji, flagi i prefiksu. Prop to transfer.
Ten sam wpis pokazuje wzorzec, który odpowiada za większość takich wycieków, i oznacza go w kodzie komentarzem EXPOSED:
export async function Page({ params: { slug } }) {
const [rows] = await sql`SELECT * FROM user WHERE slug = ${slug}`;
const userData = rows[0];
// EXPOSED: to wystawia klientowi wszystkie pola userData,
// bo przekazujemy dane z komponentu serwerowego do klienckiego
return <Profile user={userData} />;
}
Nie ma tu błędu składniowego ani ostrzeżenia w konsoli. Zapytanie SELECT * zwraca wiersz z wszystkimi kolumnami, komponent potrzebuje dwóch, a do przeglądarki jedzie cały. Jeśli w tabeli jest kolumna z tokenem integracji albo hash hasła, to one też. Generator wybiera ten kształt, bo jest najkrótszy: jeden prop zamiast wypisywania pól, jedna linia zamiast pięciu.
Druga rzecz z tego samego wpisu, która zmienia sposób czytania kodu: komponenty klienckie renderowane przez SSR należy traktować tą samą polityką bezpieczeństwa co przeglądarkę i nie powinny dostawać dostępu do uprzywilejowanych danych ani prywatnych API. To, że kod wykonał się na serwerze, nie znaczy, że jest po stronie serwera.
Dwie komendy, które to rozstrzygają na gotowej stronie
Skan katalogu z buildem odpowiada na inne pytanie — czy wartość została wklejona w kod. Tutaj trzeba odpytać działający adres i poszukać wartości w odpowiedzi:
# 1. Czy sekret jest w dokumencie, który dostaje przeglądarka
curl -s https://adres.pl/konto | grep -c -- "$TOKEN"
# 2. Czy jest w payloadzie nawigacji klienckiej (ten sam adres, inna reprezentacja)
curl -s -H 'RSC: 1' https://adres.pl/konto | grep -c -- "$TOKEN"
Druga komenda jest tą, o której się zapomina. App Router obsługuje nawigację klienta, która pobiera payload RSC — i do dopasowywania reguł w middleware używa tego samego adresu strony, z parametrem ?_rsc=... jako łamaczem cache’u. Reguła, która zakłada, że chroniony jest „ten HTML”, może przepuścić tę samą treść w drugiej formie.
W App Routerze payload wjeżdża do dokumentu inline’owymi skryptami dopisującymi do kolejki self.__next_f, ale dokładny kształt tych wywołań zmieniał się między wersjami Next.js, więc nie opieraj testu na wzorcu z nazwą globalnej. Szukaj wartości sekretu, nie opakowania. W innych stosach ten sam dokument wiezie payload pod innymi nazwami — w Nuxcie stan z useState jest, zgodnie z jego dokumentacją, serializowany do JSON-a, żeby przejść na klienta, więc wszystko, co tam włożysz, jest w odpowiedzi.
Cztery kształty, w których cała struktura przechodzi granicę
| Kształt w kodzie | Co wychodzi | Poprawka |
|---|---|---|
Wiersz z SELECT * jako jeden prop |
wszystkie kolumny tabeli | przekaż pojedyncze pola albo zwróć obiekt wyliczony w warstwie danych |
| Obiekt konfiguracji rozpakowany do propsów | każde pole, także klucze | oddaj tylko to, co komponent renderuje |
.bind(null, dane) na akcji serwerowej |
argument w formie jawnej | domknięcie zamiast .bind, a identyfikator zamiast obiektu |
| Komunikat błędu z danymi w treści | na produkcji hash, w trybie dev pełny tekst | nie buduj komunikatów z wartości wejściowych |
Trzeci wiersz wymaga rozwinięcia, bo różnica jest zaskakująca. Zmienne domknięte w akcji serwerowej są — według tego samego wpisu Vercela, opisującego Next.js 14 — szyfrowane kluczem powiązanym z identyfikatorem akcji przed wysłaniem do klienta, a klucz jest generowany przy każdym buildzie. Natomiast argumenty podane przez .bind(...) nie są szyfrowane; wpis nazywa to świadomym wyjściem ze względu na wydajność. Dwa zapisy, które w kodzie wyglądają wymiennie, mają różny skutek po stronie przeglądarki. Sprawdź, jak to działa w twojej wersji frameworka, zanim oprzesz na tym decyzję — wpis pochodzi z października 2023 r.
Czwarty wiersz dotyczy trybu, w którym uruchamiasz aplikację. Na produkcji React nie wysyła treści błędów do klienta, tylko hash, po którym kojarzy wystąpienia z logiem serwera. W trybie deweloperskim komunikaty serwera nadal lecą do klienta czystym tekstem, żeby dało się debugować. Instancja demonstracyjna postawiona „na szybko” w trybie dev oddaje więc to, czego produkcja nie oddaje.
Warstwa dostępu do danych, która nie ma czego wypuścić
Poprawka polegająca na wypisaniu pól w propsach działa dokładnie do następnego commita. Wpis Vercela proponuje zamiast tego osobną warstwę dostępu do danych jako rekomendowane podejście dla nowych projektów, w której funkcje zwracają obiekty gotowe do przekazania klientowi — nazywa je Data Transfer Objects — i w której tylko ta warstwa czyta process.env. Zasada jest taka, że ciało komponentu serwerowego widzi wyłącznie dane, do których uprawniony jest użytkownik wysyłający żądanie.
import 'server-only'
export async function getProfileDTO(slug) {
const [rows] = await sql`SELECT username, phone, team FROM user WHERE slug = ${slug}`
const userData = rows[0]
const currentUser = await getCurrentUser()
// zwracamy tylko to, co dotyczy tego zapytania, nie wszystko
return {
username: userData.username,
phone: canSeePhone(currentUser, userData.team) ? userData.phone : null,
}
}
Pierwsza linia robi tu więcej niż wygląda. Import server-only powoduje błąd budowania, jeśli komponent kliencki spróbuje zaimportować ten moduł — zamiast cichego wycieku dostajesz nieprzechodzący build. Drugi chwyt z tego samego wpisu jest jeszcze tańszy: trzymanie rekordów dostępu do danych jako instancji klas. Serializacja protokołu RSC obsługuje nadzbiór JSON-a, ale przekazanie własnej klasy nie jest wspierane i kończy się błędem — więc obiekt, który nigdy nie miał trafić do przeglądarki, sam się o to upomina.
Taint jest siatką pod liną, nie liną
React ma eksperymentalne API do oznaczania wartości, które nie mogą przejść granicy. W Next.js włącza się je flagą experimental.taint, a dokumentacja od razu ostrzega, żeby nie opierać na nim jedynej obrony przed wystawieniem danych. Włączenie flagi uruchamia też kanał eksperymentalny Reacta dla katalogu app i oznacza samo process.env, tak że nie da się przekazać go całego do komponentu klienckiego.
import { experimental_taintUniqueValue } from 'react'
async function getSystemConfig() {
const config = await configService.getConfigDetails()
experimental_taintUniqueValue(
'Nie przekazuj tokenów konfiguracji do klienta',
config,
config.SERVICE_API_KEY,
)
return config
}
Dziury w tej siatce są opisane w dokumentacji i warto je znać przed włączeniem flagi, bo inaczej daje fałszywe poczucie pokrycia:
| Warstwa | Co zatrzyma | Czego nie zatrzyma |
|---|---|---|
| Warstwa dostępu do danych z DTO | pola, których nie zwraca | tego, co sama zwróci przez pomyłkę |
import 'server-only' |
import modułu w kodzie klienckim — błąd buildu | wartości przekazanej propsem z komponentu serwerowego |
| Klasa jako rekord danych | przekazanie całej instancji | pola wyciągniętego z instancji |
taintUniqueValue |
tę konkretną wartość i jej przypisanie do nowej zmiennej | wartości pochodnych: po zmianie wielkości liter, konkatenacji, base64, podciągu |
taintObjectReference |
tę referencję obiektu | kopii obiektu i pojedynczych pól z niego |
Dokumentacja React dodaje dwa zastrzeżenia: API jest eksperymentalne i nie ma go w stabilnej wersji, a oznaczanie nie nadaje się do wartości o niskiej entropii, takich jak kody PIN czy numery telefonu — ktoś, kto kontroluje wejście, może wywnioskować oznaczoną wartość, wyliczając wszystkie możliwe. Wniosek z dokumentacji Next.js jest w tym samym duchu: lepiej modelować dane i API tak, żeby wrażliwe wartości nie trafiały tam, gdzie nie są potrzebne.
Co dopisać do przeglądu plików „use client”
Wpis Vercela kończy się listą audytową i trzy jej punkty dają się przerobić na pytania zadawane przy każdym pull requeście:
- Czy propsy komponentu klienckiego oczekują danych prywatnych i czy sygnatury typów nie są za szerokie? Prop typu
user: Userjest szerszy niżusername: stringi to po nim poznaje się ten defekt w diffie. Komponent kliencki nie powinien przyjmować więcej danych, niż minimalnie potrzebuje do swojego zadania — to samo pytanie o zakres zadaje się przy nadawaniu uprawnień integracji, gdzie kończy się ono zestawem pól, które proces widzi, i tych, których nie ma po co widzieć. - Czy pakiety bazodanowe i zmienne środowiskowe są importowane poza warstwą dostępu do danych? To jedna komenda w CI, jeśli warstwa ma własny katalog — i jedyny sposób, żeby reguła „tylko ona czyta
process.env” przetrwała kwartał. - Czy argumenty akcji serwerowych są walidowane, a użytkownik ponownie autoryzowany w środku akcji? Adnotacja
"use server"wystawia endpoint, który może wywołać każdy, kto zdobędzie identyfikator akcji, z dowolnymi argumentami. Listę argumentów traktuje się jak wrogą.
Decyzja przed najbliższym wdrożeniem
Weź dwie komendy z drugiej sekcji i puść je na wdrożonej stronie dla każdej wartości z pliku środowiskowego, nie tylko dla tych z publicznym prefiksem. To kilkanaście sekund i odpowiada na pytanie, czy problem jest hipotetyczny. Jeśli którakolwiek wartość się znajdzie, kolejność jest znana i nie zaczyna się od kodu: najpierw unieważnienie tego klucza u dostawcy, bo działa dalej niezależnie od tego, co wdrożysz, a kod dopiero potem.
Jeśli nic się nie znajdzie, zostaje jedna decyzja projektowa: czy dostęp do danych zostaje rozsypany po komponentach, czy dostaje własny katalog z server-only na wejściu. Pierwsze da się audytować tylko przez czytanie wszystkich plików "use client" przy każdym wydaniu. Drugie przenosi ten audyt do jednego miejsca, w którym da się go wykonać raz i oprzeć na nim regułę w CI.