Files
ipal-kit/docs/security.md
T
2026-09-28 16:01:03 +02:00

304 lines
12 KiB
Markdown

# 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.
```ts
// 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:
```ts
// 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:
```ts
// 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.
```ts
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
```bash
# 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`:
```ts
// 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ść
```bash
pnpm add @clocklimited/[email protected]
```
Uwaga: pakiet nie ma jeszcze stabilnego 3.0.0 — użyj konkretnej wersji beta
(albo `^3.0.0-0`, żeby dopuścić prereleasy). Sam `^3.0.0` da błąd
ERR_PNPM_NO_MATCHING_VERSION.
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)
```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)
```ts
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.