chevron-left chevron-right

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:

  1. Stylesheet, np. <link rel="stylesheet" href="/app.css"> albo CSS wygenerowany przez bundler. CSP na to pozwala dzięki style-src 'self',
  2. Tag <style> wewnątrz strony. Dozwolony tylko wtedy, gdy ma nonce,
  3. Atrybut style na pojedynczym elemencie, czyli inline style. React dodaje go za każdym razem, gdy renderuje prop style na 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:

  1. Serwer renderuje tabelę i wysyła <div class="grid" style="grid-template-columns: ...">,
  2. 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,
  3. React robi hydrację strony. Przejmuje DOM wysłany przez serwer, zamiast budować go od nowa. Z punktu widzenia Reacta atrybut style już 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 = false i 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 style na czymkolwiek, co renderuje serwer: psuje się, ale tylko po odświeżeniu strony,
  • ustawienie element.style.x z 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.