Files
ipal-kit/docs/security.md
T

12 KiB

security

Generyczne nagłówki bezpieczeństwa HTTP (HSTS, X-Frame-Options, nosniff, Referrer-Policy, Permissions-Policy) jako funkcja do next.config. CSP CELOWO pominięte — zależy od domen projektu, zostaje w projekcie.

Zasada

Nagłówki, które są IDENTYCZNE między projektami, plugin dostarcza raz. CSP (Content-Security-Policy) wymaga znajomości domen konkretnego projektu (skąd ładują się skrypty, obrazy, fonty, analytics), więc nie może być generyczne — zostaje w projekcie, dodawane przez additional.

Użycie — next.config.ts

Nagłówki wpina się w next.config, NIE w proxy — bo muszą pokryć CAŁĄ aplikację (też /admin, statyki), a proxy pomija te trasy.

// next.config.ts
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'
import { buildSecurityHeaders } from '@intecion/ipal-kit'

const securityHeaders = buildSecurityHeaders({
  hsts: process.env.NODE_ENV === 'production',   // WAŻNE: off w dev (http)
  additional: [
    // CSP projektu — zna swoje domeny:
    // { key: 'Content-Security-Policy', value: "default-src 'self'; ..." },
  ],
})

const nextConfig: NextConfig = {
  async headers() {
    return [{ source: '/:path*', headers: securityHeaders }]
  },
}

export default withPayload(nextConfig)

Opcje

Opcja Domyślnie Rola
hsts true Strict-Transport-Security (wymuś HTTPS)
hstsMaxAge 63072000 (2 lata) max-age HSTS w sekundach
hstsIncludeSubDomains true HSTS na subdomeny
hstsPreload false preload (tylko jeśli zgłaszasz do listy)
frameOptions 'DENY' X-Frame-Options (anty-clickjacking)
referrerPolicy 'strict-origin-when-cross-origin' Referrer-Policy
permissionsPolicy blokuje camera/mic/geolocation Permissions-Policy
additional [] dodatkowe nagłówki (np. CSP); same-key nadpisuje

PUŁAPKA — HSTS w dev

HSTS nad HTTP na localhost może zablokować przeglądarkę na HTTPS dla localhost. ZAWSZE wyłączaj w dev: hsts: process.env.NODE_ENV === 'production'.

Nadpisywanie i CSP

additional z tym samym kluczem NADPISUJE domyślny (np. zmień X-Frame-Options na SAMEORIGIN). Nowy klucz (jak CSP) dodaje. CSP zawsze przez additional — plugin go nie generuje, bo zależy od projektu.

Dlaczego CSP zostaje w projekcie (nie plugin)

HSTS, nosniff, Referrer-Policy są IDENTYCZNE dla każdego projektu → plugin je generuje. CSP wylicza KONKRETNE domeny, z których projekt ładuje (jego R2, analytics, Turnstile, fonty). Generyczny CSP byłby albo za luźny (* = bezużyteczny), albo psułby stronę. Więc plugin daje mechanizm (additional), projekt dostarcza CSP dopasowany do siebie.

buildCsp — generator CSP (zalecane zamiast ręcznego)

Zamiast pisać surowy CSP w każdym projekcie (ryzyko pominięcia base-uri, object-src), użyj buildCsp — ma twarde reguły OWASP/Lighthouse wbudowane, a Ty włączasz tylko flagi tego, co projekt ładuje:

// next.config.ts
import { buildCsp, buildSecurityHeaders } from '@intecion/ipal-kit'

const csp = buildCsp({
  mode: 'report-only',              // zacznij tu; 'enforce' gdy konsola czysta
  r2Url: process.env.R2_PUBLIC_URL, // media R2 → img-src
  turnstile: true,                  // challenges.cloudflare.com → script/frame/connect
  analytics: true,                  // GTM + GA
  youtube: true,                    // youtube → frame-src
  googleMaps: true,                 // mapy Google
  // extra: { 'script-src': ['https://inny-skrypt.pl'] },  // dodatkowe źródła
})

const securityHeaders = buildSecurityHeaders({
  hsts: process.env.NODE_ENV === 'production',
  additional: [csp],
})

Twarde reguły wbudowane (zawsze, nie da się zapomnieć): base-uri 'self', object-src 'none', frame-ancestors 'none'. To te, które Lighthouse/OWASP wymagają, a łatwo je pominąć pisząc CSP ręcznie.

buildCsp NIE dodaje 'unsafe-eval' (osłabia CSP) — dodaj przez extra tylko jeśli biblioteka tego wymaga. mode: 'report-only' daje nagłówek …-Report-Only; 'enforce' daje Content-Security-Policy.

CSP dalej „w projekcie" (Ty wybierasz flagi wg tego, co ładujesz), ale skeleton jest z pluginu — każdy projekt ma ten sam zahardowany fundament.

Budowa CSP — ręcznie (jeśli potrzebujesz pełnej kontroli)

Domenę mediów czytaj z R2_PUBLIC_URL (env), nie zaszywaj. Resztę źródeł dopasuj do tego, co projekt faktycznie ładuje:

// next.config.ts
const r2Url = process.env.R2_PUBLIC_URL || ''

const csp = [
  "default-src 'self'",
  // skrypty: self + Turnstile (Cloudflare) + analytics (GTM/GA jeśli używasz)
  "script-src 'self' 'unsafe-inline' https://challenges.cloudflare.com https://www.googletagmanager.com",
  // style: self + inline (Tailwind) + Google Fonts
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
  // obrazy: self + media R2 (z env!) + data:
  `img-src 'self' data: ${r2Url}`.trim(),
  "font-src 'self' https://fonts.gstatic.com data:",
  "connect-src 'self' https://www.google-analytics.com",
  // ramki: Turnstile (widget captcha)
  "frame-src https://challenges.cloudflare.com",
  "form-action 'self'",
  "frame-ancestors 'none'",   // zastępuje X-Frame-Options w nowych przeglądarkach
].join('; ')

const securityHeaders = buildSecurityHeaders({
  hsts: process.env.NODE_ENV === 'production',
  additional: [{ key: 'Content-Security-Policy', value: csp }],
})

Dopasuj źródła do projektu: mapy Google (https://maps.googleapis.com, https://*.google.com), inne embedy, inne analytics. To, czego nie wymienisz, zostanie zablokowane.

WDRAŻAJ CSP OSTROŻNIE — najpierw Report-Only

CSP za ścisły psuje stronę (blokuje skrypty/style/obrazy). NIGDY nie wdrażaj enforcing CSP na ślepo. Metoda bezpieczna:

  1. Najpierw raportowanie — użyj klucza Content-Security-Policy-Report-Only (nie Content-Security-Policy). Przeglądarka RAPORTUJE naruszenia w konsoli, ale NIE blokuje — strona działa normalnie.
    additional: [{ key: 'Content-Security-Policy-Report-Only', value: csp }]
    
  2. Otwórz stronę → DevTools → Console → szukaj „Content Security Policy" violations. Każde naruszenie = brakująca domena. Dodaj ją do odpowiedniej dyrektywy CSP.
  3. Przejdź przez cały serwis — strona główna, formularze (Turnstile!), galeria (obrazy R2), strony z mapą/embedami. Zbierz wszystkie naruszenia.
  4. Dopiero gdy konsola czysta → zmień klucz na Content-Security-Policy (enforcing). Teraz CSP chroni, nie psując.

Weryfikacja nagłówków na produkcji

# sprawdź, które nagłówki faktycznie wychodzą:
curl -sI https://<DOMENA>/pl | grep -i "strict-transport\|content-type-options\|referrer\|content-security\|x-frame"

Jeśli HSTS/nosniff/Referrer są, a CSP brak → dodaj CSP (wyżej). Jeśli BRAK wszystkich mimo buildSecurityHeaders w config → sprawdź, czy headers() jest wpięte i czy Cloudflare (jeśli przed aplikacją) nie filtruje nagłówków.

Uwaga Cloudflare: jeśli CF jest przed aplikacją, może nadpisywać/filtrować nagłówki. Wtedy ustaw je też w CF (Transform Rules → Modify Response Header) albo upewnij się, że CF przepuszcza nagłówki z origin.

COOP (Cross-Origin-Opener-Policy) — domyślnie włączony

buildSecurityHeaders wysyła domyślnie Cross-Origin-Opener-Policy: same-origin — izoluje kontekst przeglądarki (ochrona przed XS-Leaks / Spectre, wyciekiem window.opener). Uniwersalny nagłówek, więc z automatu.

  • Domyślnie same-origin (najbezpieczniejsze)
  • coop: 'same-origin-allow-popups' — jeśli otwierasz popupy OAuth/płatności wymagające window.opener
  • coop: false — wyłącz (rzadko potrzebne)

Trusted Types — NIE wdrażać (na teraz)

NIE wymuszaj require-trusted-types-for 'script'. Powód:

  • Audyt Lighthouse to „Bez oceny" (informacyjny/eksperymentalny w Chromium)
  • Wymuszenie bez kompleksowego silnika polityk w Next/React powoduje TypeError przy zewnętrznych skryptach manipulujących DOM stringami (Turnstile, GA)
  • Zysk bezpieczeństwa nie równoważy ryzyka zepsucia strony

Zostaw Trusted Types poza CSP, dopóki Next/React nie da natywnego wsparcia.

Zmiana ścieżki panelu admina (/admin → /its)

Ukrycie panelu przed botami skanującymi znane ścieżki (/admin, /wp-admin). Plugin ustawia ścieżkę przez opcję adminRoute:

// payload.config.ts
ipalKit({
  i18n: i18nConfig,
  adminRoute: '/its',   // panel pod /its zamiast /admin
})

WYMAGANE — przenieś folder panelu w projekcie

Plugin ustawia config.routes.admin, ale NIE tworzy plików w app/ projektu. Musisz przenieść folder panelu, żeby ścieżka zadziałała:

# PRZED:
app/(payload)/admin/[[...segments]]/page.tsx
app/(payload)/admin/[[...segments]]/not-found.tsx

# PO (nazwa folderu = adminRoute bez ukośnika):
app/(payload)/its/[[...segments]]/page.tsx
app/(payload)/its/[[...segments]]/not-found.tsx

Bez przeniesienia folderu: config.routes.admin = '/its', ale /its daje 404 (brak pliku), a /admin też nie działa (config zmieniony). Oba muszą się zgadzać.

To OBSCURITY, nie SECURITY

Zmiana ścieżki utrudnia automatyczne skany, ale NIE jest zabezpieczeniem. Prawdziwa ochrona panelu:

  • 2FA dla każdego użytkownika (planowane — wymuszenie przez plugin)
  • Silne hasła
  • Rate limiting na logowaniu
  • IP allowlist (jeśli panel tylko dla zespołu)
  • buildSecurityHeaders (nagłówki)

Zmiana /admin → /its to warstwa (odsiewa głupie boty), nie zamek. Traktuj jako dodatek do prawdziwych zabezpieczeń, nie zamiast nich.

2FA (TOTP) — WYMUSZONE dla każdego użytkownika

Plugin wymusza dwuskładnikowe uwierzytelnianie (TOTP) dla WSZYSTKICH użytkowników panelu — bez możliwości wyłączenia per użytkownik. Każdy projekt ma to z automatu. Używa sprawdzonego @clocklimited/payload-2fa (wrapuje access control — TOTP sprawdzane przed dostępem do DANYCH, nie tylko UI panelu).

Zależność

pnpm add @clocklimited/payload-2fa

To PEER dependency — ipal-kit importuje ją dynamicznie tylko gdy 2FA włączone (domyślnie). Bez niej i z włączonym 2FA plugin rzuci jasny błąd.

Konfiguracja (payload.config.ts)

ipalKit({
  i18n: i18nConfig,
  twoFactor: {
    issuer: 'Nazwa Firmy',   // pokazywane w aplikacji authenticator (Google Auth itp.)
    // collectionSlug: 'users',  // domyślnie 'users'
  },
})

Plugin ustawia forceSetup: true — każdy użytkownik MUSI skonfigurować TOTP po zalogowaniu (przekierowanie na setup). Nie ma opcji „włącz/wyłącz" dla użytkownika.

Wyłączenie (ODRADZANE)

twoFactor: false   // TYLKO gdy projekt naprawdę nie może użyć 2FA (rzadkie)

Domyślnie 2FA jest WYMUSZONE. false to świadoma rezygnacja — unikaj.

Jak działa dla użytkownika

  1. Loguje się (email + hasło)
  2. Przy pierwszym logowaniu: przekierowanie na Setup TOTP (QR + sekret)
  3. Skanuje QR aplikacją (Google Authenticator, Authy, 1Password, Microsoft Auth)
  4. Wpisuje kod → 2FA aktywne
  5. Kolejne logowania: email + hasło + kod TOTP

Reset 2FA (admin)

Admin może zresetować 2FA innego użytkownika (gdy zgubi telefon) — przez adminManageAccess w konfiguracji @clocklimited. Patrz jego dokumentacja.

Dlaczego @clocklimited, nie inne

Wybrany, bo wrapuje ACCESS CONTROL (TOTP przed dostępem do danych) + forceSetup (wymuszenie dla wszystkich). Inne pluginy 2FA dla Payload gatują tylko nawigację /admin — user z hasłem może omijać przez REST/GraphQL/Bearer. @clocklimited chroni dostęp do danych, nie tylko UI.