Dlaczego moje tabele składały się w jedną kolumnę? Strict CSP vs inline styles w React
Content Security Policy to obecnie jedna z pierwszych rzeczy, które sprawdza audyt bezpieczeństwa aplikacji internetowej. To świetna ochrona przed atakami XSS, ale potrafi też zepsuć aplikację w sposób, który bardzo trudno zauważyć.
Jakiś czas temu zajmowałem się zgłoszeniem błędu, które brzmiało niewinnie: tabela wygląda dobrze, kiedy przechodzisz do niej z menu, ale po odświeżeniu strony rozsypuje się w jedną długą kolumnę. Sama poprawka nie jest skomplikowana. Ciekawsze jest to, dlaczego w ogóle do tego doszło. O tym w dalszej części tekstu.
Żeby pokazać wszystko krok po kroku, przygotowałem małą aplikację demo (Next.js 16, React 19, Chrome 154). Wszystkie wyniki, które zobaczysz niżej, pochodzą z jej uruchomienia, więc możesz je sprawdzić samodzielnie: github.com/sunpietro/csp-issue-demo.
Od czego się zaczęło?
Projekt dostał właśnie strict CSP oparte na nonce, bo audyt bezpieczeństwa
wykazał, że aplikacja nie wysyła nagłówka Content-Security-Policy. Polityka
była bardzo podobna do tej z przewodnika Next.js:
default-src 'self';
script-src 'self' 'nonce-RANDOM' 'strict-dynamic';
style-src 'self' 'nonce-RANDOM';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
Tabele w aplikacji były zbudowane na CSS Grid, a każda dostawała układ kolumn w propsie:
<div role="table" className="grid" style={{ gridTemplateColumns: columns }}>
Wygląda niewinnie, prawda? Zapamiętaj ten kawałek kodu, jeszcze do niego wrócimy.
Czym jest Content Security Policy?
Krótko, czym jest CSP? Jest to lista reguł, którą serwer wysyła razem ze stroną w nagłówku HTTP. Reguły mówią przeglądarce, co strona może załadować i uruchomić. Wszystko, czego nie ma na liście, zostaje zablokowane, nawet jeśli znajduje się w kodzie HTML. Dzięki temu, jeśli atakujący zdoła wstrzyknąć do strony swój kod, to w większości przypadków nic on nie zrobi.
Nonce to losowa wartość generowana przy każdym requeście. Serwer wpisuje ją
do CSP i dokleja tę samą wartość do swoich tagów <script> i <style>.
Przeglądarka uruchamia tylko te tagi, których nonce się zgadza. Jeśli chcesz
poczytać więcej o poszczególnych dyrektywach, to polecam zajrzeć do MDN, gdzie
zostało to przystępnie wytłumaczone.
W polityce powyżej jest jedna rzecz, którą łatwo przeoczyć. Nie ma w niej ani
słowa o atrybutach style i wcale nie musi być. Dyrektywa style-src-attr,
która odpowiada za atrybuty style="...", gdy nie jest ustawiona, dziedziczy
wartość z style-src. A style-src nie zawiera 'unsafe-inline'. Co to
oznacza? To oznacza, że przeglądarka odrzuca każdy atrybut style wysłany
przez serwer.
Trzy sposoby na nadanie stylu elementowi
Element może dostać style na jeden z trzech sposobów:
- Stylesheet, np.
<link rel="stylesheet" href="/app.css">albo CSS wygenerowany przez bundler. CSP na to pozwala dziękistyle-src 'self', - Tag
<style>wewnątrz strony. Dozwolony tylko wtedy, gdy ma nonce, - Atrybut
stylena pojedynczym elemencie, czyli inline style. React dodaje go za każdym razem, gdy renderuje propstylena serwerze.
Żeby sprawdzić, co dokładnie jest blokowane, przygotowałem w demo stronę
/experiment. Testuje ona siedem wariantów i odczytuje, co przeglądarka
faktycznie zastosowała:
| Jak styl trafia do elementu | Wynik |
|---|---|
| Atrybut style w HTML-u wysłanym przez serwer | Zablokowany |
Tag <style> z nonce |
Zastosowany |
Tag <style> bez nonce |
Zablokowany |
<link rel="stylesheet"> do pliku z tego samego originu |
Zastosowany |
Zaufany skrypt ustawia element.style.backgroundColor |
Zastosowany |
Zaufany skrypt wywołuje setAttribute('style', ...) |
Zablokowany |
Prop style w React na elemencie utworzonym w przeglądarce |
Zastosowany |
Dla zablokowanego atrybutu Chrome wyświetla w konsoli taki komunikat:
Applying inline style violates the following Content Security Policy directive 'style-src 'self' 'nonce-...''. Either the 'unsafe-inline' keyword, a hash ('sha256-...'), or a nonce ('nonce-...') is required to enable inline execution. The action has been blocked.
Uważaj na ten komunikat, bo sugeruje, że nonce może rozwiązać problem. W
przypadku atrybutów nie może. W specyfikacji CSP nonce należy do elementu,
takiego jak <script> czy <style>, a atrybut po prostu nie ma go gdzie
przenieść. Inline styles można dopuścić tylko na dwa sposoby: przez
'unsafe-inline', które wpuszcza wszystkie, albo przez hash każdej dozwolonej
wartości razem z 'unsafe-hashes'.
Teraz spójrz na piąty i ostatni wiersz. Zaufany skrypt, który ustawia
element.style, nie jest blokowany. I tu jest klucz do całego problemu: CSP
sprawdza style, które przychodzą jako tekst do sparsowania, czyli atrybut w
HTML-u albo setAttribute ze stringiem. Zmiana właściwości w element.style
idzie przez CSS Object Model (CSSOM), a tej ścieżki CSP w ogóle nie obejmuje.
Dlaczego tylko po odświeżeniu?
Przejdźmy przez to, co dzieje się po odświeżeniu strony:
- Serwer renderuje tabelę i wysyła
<div class="grid" style="grid-template-columns: ...">, - Przeglądarka parsuje HTML, sprawdza CSP i odrzuca atrybut. Grid nie ma teraz definicji kolumn, więc układa każdą komórkę w jednej kolumnie,
- React robi hydrację strony. Przejmuje DOM wysłany przez serwer, zamiast
budować go od nowa. Z punktu widzenia Reacta atrybut
stylejuż tam jest, więc nie ustawia go drugi raz. Odrzucony styl zostaje odrzucony.
Przy client-side navigation wygląda to zupełnie inaczej. Z serwera nie
przychodzi żaden HTML. React tworzy tabelę w przeglądarce i nakłada prop style
przez element.style, czyli dokładnie tą ścieżką CSSOM z ostatniego wiersza
eksperymentu. CSP na to pozwala.
Ten sam komponent, te same propsy, ta sama polityka. Po odświeżeniu zepsute, po kliknięciu wszystko w porządku. Nic dziwnego, że było to mylące.
Dlaczego nikt tego nie zauważył?
Bo CSP było wyłączone wszędzie tam, gdzie pracowali programiści i gdzie chodziły
testy. To bardzo częsta konfiguracja i, szczerze mówiąc, oficjalny przewodnik
wręcz do niej zachęca. Jego przykład dla środowiska deweloperskiego luzuje
regułę dla stylów do style-src 'self' 'unsafe-inline', co przepuszcza każdy
atrybut style.
Demo pozwala odpalić ten sam build produkcyjny w trzech trybach, więc łatwo zobaczyć różnicę na stronie z błędem:
CSP_MODE=off: 4 kolumny po odświeżeniu, 4 kolumny po client-side navigation, brak komunikatów CSP. Wszystko wygląda dobrze,CSP_MODE=report-only: również 4 kolumny wszędzie, ale po odświeżeniu w konsoli pojawia się jeden komunikat CSP,CSP_MODE=enforce: 1 kolumna po odświeżeniu, 4 kolumny po client-side navigation i ten sam komunikat CSP.
Z wyłączonym CSP po prostu nie ma czego szukać. Tryb report-only to bardzo przydatny środek: strona dalej działa, a przeglądarka dokładnie mówi, co zablokowałoby CSP w trybie enforce. Moim zdaniem to najlepszy sposób na wprowadzanie nowej polityki.
Prawdziwym rozwiązaniem jest jednak test, który działa tam, gdzie CSP jest prawdziwe: build produkcyjny, CSP w trybie enforce i sprawdzenie, które pada przy każdym komunikacie CSP. W Playwright to tylko kilka linijek kodu:
function collectCspViolations(page: Page): string[] {
const violations: string[] = []
page.on('console', message => {
if (message.text().includes('Content Security Policy')) {
violations.push(message.text())
}
})
return violations
}
Koniecznie testuj obie ścieżki, odświeżenie strony i client-side navigation. Za chwilę zobaczysz, że poprawka może przejść jedną z nich i oblać drugą.
Jak to naprawić?
W demo sprawdziłem cztery różne poprawki. Oto podsumowanie:
| Poprawka | Odświeżenie strony | Client-side navigation | Koszt |
|---|---|---|---|
| 1. Statyczna klasa | Działa | Działa | Tylko dla układów znanych w czasie builda |
| 2. Ustawienie wartości przez CSSOM | Działa | Działa | Tabela jest niewidoczna, dopóki nie ruszy JavaScript |
3. Tag <style> z nonce |
Działa | Psuje się | Nonce zmienia się przy każdym requeście |
| 4. Stylesheet z własnego originu | Działa | Działa | Dodatkowy request i input do walidacji |
Poprawka 1: statyczna klasa
Jeśli znasz wszystkie układy w czasie builda, to po prostu umieść je w pliku CSS:
.cols-packages {
grid-template-columns: minmax(12rem, 2fr) 7rem 9rem 6rem;
}
To najlepsze rozwiązanie wszędzie tam, gdzie pasuje. Jeśli korzystasz z
Tailwinda, to arbitrary values, np. grid-cols-[minmax(12rem,2fr)_7rem],
również zadziałają, ale tylko wtedy, gdy dokładnie taka nazwa klasy występuje
gdzieś w kodzie źródłowym. Tailwind generuje CSS na podstawie nazw klas, które
znajdzie w czasie builda, więc układu przekazanego w propsie po prostu nie
zobaczy.
Poprawka 2: ustawienie wartości przez CSSOM
Skoro CSP ignoruje CSSOM, to client component może ustawić wartość po zamontowaniu:
useLayoutEffect(() => {
const element = ref.current
if (!element) {
return
}
element.style.gridTemplateColumns = columns
element.classList.remove('cssom-pending')
}, [columns])
Działa na obu ścieżkach. Minusem jest to, że tabela wyrenderowana na serwerze nie ma układu aż do hydracji, więc demo do tego momentu ją ukrywa. Zamiast zepsutej tabeli dostajesz więc przez chwilę niewidoczną, co może opóźnić Largest Contentful Paint. A bez JavaScriptu tabela w ogóle się nie pojawi. Dla tooltipa to wystarczy, ale dla głównej treści strony już nie.
Poprawka 3: tag <style> z nonce
CSP dopuszcza tag <style> z nonce, więc serwer może sam zapisać regułę:
const nonce = (await headers()).get('x-nonce') ?? undefined
return (
<>
<style nonce={nonce}>{`.${className} { grid-template-columns: ${columns}; }`}</style>
<PackageTable className={className} />
</>
)
To poprawka, którą większość osób proponuje jako pierwszą, więc ją też
sprawdziłem. Po odświeżeniu strony działa. Przy client-side navigation psuje
się, z tym samym komunikatem co pierwotny błąd. Dlaczego? Bo client-side
navigation to też request. Next.js pobiera server components nowej strony,
proxy uruchamia się również dla tego requestu i generuje zupełnie nowy nonce.
Nowy tag <style> ma ten nowy nonce, a stronę wciąż obowiązuje polityka, która
przyszła z pierwszym dokumentem. W demo nonce dokumentu zaczynał się od
MjY1ODNi, a nonce nowego tagu od ZGFhZDMx.
Da się to obejść, odczytując nonce dokumentu w przeglądarce i przekazując go dalej, ale wtedy zarządzasz nonce'ami ręcznie, żeby uniknąć problemu, którego stylesheet w ogóle nie ma.
Poprawka 4: stylesheet z własnego originu
Stylesheet z własnego originu jest dozwolony przez style-src 'self'. To
dlaczego nie pozwolić serwerowi wygenerować go dla każdego układu? Route handler
może zwrócić regułę jako CSS:
// GET /css/grid?c=<grid-template-columns value>
export function GET(request: NextRequest) {
const columns = request.nextUrl.searchParams.get('c') ?? ''
if (!isValidTrackList(columns)) {
return new Response('/* invalid track list */\n', {
status: 400,
headers: { 'Content-Type': 'text/css; charset=utf-8' },
})
}
const css = `.${gridClassName(columns)} { grid-template-columns: ${columns}; }\n`
return new Response(css, {
headers: {
'Content-Type': 'text/css; charset=utf-8',
'Cache-Control': 'public, max-age=31536000, immutable',
'X-Content-Type-Options': 'nosniff',
},
})
}
A komponent linkuje do niego w następujący sposób:
<link
rel="stylesheet"
href={`/css/grid?c=${encodeURIComponent(columns)}`}
precedence="grid"
/>
<PackageTable className={gridClassName(columns)} />
Większość roboty wykonuje tutaj prop precedence. W React 19 zamienia on link w
stylesheet resource: React przenosi go do <head>, renderuje każdy URL tylko raz
i czeka na stylesheet, zanim pokaże treść, która od niego zależy. Chciałem to
zobaczyć na własne oczy, więc opóźniłem stylesheet o 1,5 sekundy w przeglądarce
testowej. Przy client-side navigation tabeli nie było wcale przez około 1,45
sekundy, a kiedy się pojawiła, miała już cztery kolumny. Ani razu nie pokazała
jednej. Po odświeżeniu strony stylesheet w <head> i tak blokuje renderowanie.
Są dwa szczegóły, które sprawiają, że ta wersja jest tania i bezpieczna.
Wszystko wyliczaj z wartości. Nazwa klasy to krótki hash stringu z kolumnami,
liczony tą samą funkcją na serwerze i w przeglądarce. Ten sam układ zawsze daje
ten sam URL, więc dzięki immutable przeglądarka pobiera go raz i używa na
każdej stronie. Gdyby do URL-a trafiło coś zależnego od konkretnej instancji, np.
useId z Reacta, każda tabela oznaczałaby nowy request.
Waliduj input. To łatwo przeoczyć. Wszystko, co ten route zwróci, staje się
CSS-em, któremu ufa twoje CSP. Gdyby wklejał input bez zmian, to każdy, kto
znajdzie w twojej aplikacji błąd pozwalający wstrzyknąć HTML, mógłby dodać
<link> do tego route'a i przemycić własny CSS, który 'self' przepuści. Mógłby
ukryć prawdziwą treść, narysować fałszywy formularz na stronie albo wyciągać
wartości atrybutów za pomocą selektorów CSS. Wartość grid-template-columns
potrzebuje tylko liter, cyfr, spacji, kropek, przecinków, myślników, znaków
procentu i nawiasów, i niczego, co mogłoby zamknąć regułę albo otworzyć nową:
const TRACK_LIST = /^[a-z0-9.%(),\s-]{1,200}$/i
Route sam też buduje nazwę klasy, zamiast przyjmować ją z URL-a. Tym samym atakujący może wstrzyknąć najwyżej układ kolumn.
Biblioteki, które wstrzykują style w runtime
Ta sama zasada dotyczy kodu zewnętrznego:
- Biblioteki, które dodają tag
<style>w runtime, są blokowane, chyba że dołączą twój nonce. Klasycznym przykładem są biblioteki ikon: ikony renderują się w surowym rozmiarze SVG, bo ich CSS nigdy nie został nałożony. Większość z nich ma na to przełącznik. W Font Awesome wystarczy ustawićconfig.autoAddCss = falsei zaimportować jego plik CSS w czasie builda. Jeśli korzystasz z CSS-in-JS, sprawdź, czy biblioteka obsługuje nonce, zanim ją wybierzesz, - Biblioteki do pozycjonowania elementów, takie jak Floating UI, ustawiają
współrzędne przez prop
style. To nie jest problem, gdy floating element powstaje w przeglądarce po kliknięciu, bo React nakłada prop przez CSSOM. Natomiast popover, który serwer wyrenderuje od razu otwarty, trafi na regułę dla atrybutów.
Checklista
Na koniec krótka checklista tego, co działa przy strict CSP:
- klasy z własnych plików CSS, także z Tailwinda: działa,
- arbitrary values w Tailwindzie: działa, jeśli dokładnie taka nazwa klasy jest w kodzie źródłowym,
- import pliku CSS biblioteki: działa,
- prop
stylena czymkolwiek, co renderuje serwer: psuje się, ale tylko po odświeżeniu strony, - ustawienie
element.style.xz własnego kodu: działa, - wywołanie
setAttribute('style', ...): zablokowane, - tag
<style>z nonce w server componencie: psuje się przy client-side navigation, - wartość znana dopiero w runtime: statyczna klasa, jeśli się da, a jeśli nie, route ze stylesheetem i walidacją,
- biblioteka, która w runtime wstrzykuje
<style>: wyłącz wstrzykiwanie i zaimportuj jej CSS.
Podsumowanie
W tym wpisie wyjaśniłem, dlaczego strict CSP potrafi zepsuć tabelę tylko po odświeżeniu strony, i przedstawiłem cztery sposoby na naprawienie tego problemu. Strict CSP to świetna rzecz, ale chroni cię tylko tam, gdzie jest włączone. W moim przypadku błąd przez jakiś czas spokojnie żył na produkcji, tylko dlatego, że nikt nie uruchomił aplikacji z CSP w trybie enforce wcześniej niż użytkownicy.
Jeśli chcesz uniknąć takich niespodzianek, polecam trzymać się kilku zasad:
- Trzymaj style w plikach CSS, a nie w atrybutach,
- Testuj build produkcyjny z CSP w trybie enforce,
- Sprawdzaj zarówno odświeżenie strony, jak i client-side navigation,
- Waliduj wszystko, co trafia do generowanego CSS-a.
Mam nadzieję, że ten tekst okaże się przydatny. Jeśli masz pytania albo uwagi, to zachęcam do podzielenia się nimi w komentarzach.