Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React
Kompleksowe praktyki bezpieczeństwa w React
W tej lekcji6
Wystarczy jedno pole komentarza bez kontroli, by obcy sygnał przejął stery: atakujący wkleja HTML ze skryptem, a przeglądarka każdego pasażera ten skrypt wykonuje. Bezpieczeństwo aplikacji React to kilka warstw osłon, od escapowania w JSX, przez tokeny i zależności, po zasady OWASP Top 10.
1. Cross-Site Scripting (XSS)
XSS polega na wstrzyknięciu skryptu, który wykona się w przeglądarce ofiary. React domyślnie escapuje każdą wartość w nawiasach klamrowych JSX, zamieniając znaki specjalne HTML na bezpieczne encje:
1// React automatycznie escapuje te wartości
2function UserProfile({ userData }) {
3 return (
4 <div>
5 {/* Bezpieczne - React escapuje zawartość */}
6 <h1>{userData.name}</h1>
7 <p>{userData.description}</p>
8
9 {/* NIEBEZPIECZNE - omija escapowanie */}
10 <div dangerouslySetInnerHTML={{ __html: userData.bio }} />
11 </div>
12 );
13}{userData.name} wyświetli się jako tekst, nawet jeśli ktoś wpisze tam tag <script>. Wyjątkiem jest dangerouslySetInnerHTML, które wstawia surowy HTML i omija tę ochronę.
Sanityzacja z DOMPurify
Gdy musisz pokazać HTML z niezaufanego źródła, na przykład treść z edytora tekstu, najpierw przepuść go przez DOMPurify:
1// Bezpieczne komponenty z walidacją
2function SafeHTMLRenderer({ content, allowedTags = [] }) {
3 // Biblioteka DOMPurify do oczyszczania HTML
4 const cleanHTML = DOMPurify.sanitize(content, {
5 ALLOWED_TAGS: allowedTags,
6 ALLOWED_ATTR: ['href', 'title', 'alt', 'src'],
7 ALLOW_DATA_ATTR: false
8 });
9
10 return (
11 <div dangerouslySetInnerHTML={{ __html: cleanHTML }} />
12 );
13}
14
15// Walidacja URL-i
16function SafeLink({ href, children, ...props }) {
17 const isValidURL = (url) => {
18 try {
19 const urlObj = new URL(url);
20 // Zezwalaj tylko na bezpieczne protokoły
21 return ['http:', 'https:', 'mailto:'].includes(urlObj.protocol);
22 } catch {
23 return false;
24 }
25 };
26
27 if (!isValidURL(href)) {
28 console.warn('Niebezpieczny URL zablokowany:', href);
29 return <span>{children}</span>;
30 }
31
32 return (
33 <a
34 href={href}
35 {...props}
36 rel="noopener noreferrer" // Zabezpieczenie przed tabnabbing
37 >
38 {children}
39 </a>
40 );
41}
42
43// Bezpieczne renderowanie obrazów
44function SafeImage({ src, alt, ...props }) {
45 const [imageSrc, setImageSrc] = useState(null);
46 const [error, setError] = useState(false);
47
48 useEffect(() => {
49 // Walidacja źródła obrazu
50 if (src && typeof src === 'string') {
51 try {
52 const url = new URL(src);
53 if (['http:', 'https:', 'data:'].includes(url.protocol)) {
54 setImageSrc(src);
55 } else {
56 setError(true);
57 }
58 } catch {
59 setError(true);
60 }
61 }
62 }, [src]);
63
64 if (error) {
65 return <div className="image-error">Nieprawidłowe źródło obrazu</div>;
66 }
67
68 return imageSrc ? (
69 <img
70 src={imageSrc}
71 alt={alt}
72 {...props}
73 onError={() => setError(true)}
74 />
75 ) : null;
76}Zasada brzmi: dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }}, nigdy surowy HTML. Zwykłego tekstu z pól formularza nie musisz sanityzować, bo React i tak go escapuje. SafeLink przepuszcza tylko protokoły http:, https: i mailto:, a SafeImage sprawdza źródło obrazka.
Content Security Policy
Druga osłona działa w przeglądarce: nagłówek Content Security Policy mówi, skąd wolno ładować skrypty, style i obrazy:
1// Konfiguracja nagłówka CSP
2const cspConfig = {
3 'default-src': ["'self'"],
4 'script-src': [
5 "'self'",
6 "'unsafe-inline'", // Tylko w development
7 'https://apis.google.com',
8 'https://cdn.jsdelivr.net'
9 ],
10 'style-src': [
11 "'self'",
12 "'unsafe-inline'", // Dla CSS-in-JS
13 'https://fonts.googleapis.com'
14 ],
15 'img-src': [
16 "'self'",
17 'data:',
18 'https:',
19 'blob:'
20 ],
21 'font-src': [
22 "'self'",
23 'https://fonts.gstatic.com'
24 ],
25 'connect-src': [
26 "'self'",
27 'https://api.example.com',
28 'wss://socket.example.com'
29 ],
30 'frame-ancestors': ["'none'"],
31 'form-action': ["'self'"],
32 'upgrade-insecure-requests': []
33};
34
35// Konfiguracja CSP w Next.js
36// next.config.js
37const securityHeaders = [
38 {
39 key: 'Content-Security-Policy',
40 value: Object.entries(cspConfig)
41 .map(([directive, sources]) =>
42 `${directive} ${sources.join(' ')}`
43 )
44 .join('; ')
45 },
46 {
47 key: 'X-Frame-Options',
48 value: 'DENY'
49 },
50 {
51 key: 'X-Content-Type-Options',
52 value: 'nosniff'
53 },
54 {
55 key: 'Referrer-Policy',
56 value: 'strict-origin-when-cross-origin'
57 },
58 {
59 key: 'Permissions-Policy',
60 value: 'camera=(), microphone=(), geolocation=()'
61 }
62];
63
64module.exports = {
65 async headers() {
66 return [
67 {
68 source: '/(.*)',
69 headers: securityHeaders
70 }
71 ];
72 }
73};cspConfig zamienia się w jeden nagłówek, a headers() w next.config.js dokłada go do każdej odpowiedzi. 'unsafe-inline' w script-src wyłącza dużą część ochrony, więc trzymaj go tylko w środowisku deweloperskim.
2. JWT Security
Token JWT to przepustka na pokład, więc jego kradzież oznacza przejęcie konta. SecureTokenManager pokazuje dwa sposoby przechowywania, a useAuth co 5 minut sprawdza ważność tokenu:
1// Bezpieczne przechowywanie i obsługa JWT
2class SecureTokenManager {
3 constructor() {
4 this.tokenKey = 'auth_token';
5 this.refreshTokenKey = 'refresh_token';
6 }
7
8 // Przechowywanie w httpOnly cookies (najlepsze)
9 setTokens(accessToken, refreshToken) {
10 // Wysyłanie na backend do ustawienia httpOnly cookies
11 fetch('/api/auth/set-tokens', {
12 method: 'POST',
13 headers: { 'Content-Type': 'application/json' },
14 body: JSON.stringify({ accessToken, refreshToken })
15 });
16 }
17
18 // Alternatywnie - sessionStorage (mniej bezpieczne)
19 setTokensInStorage(accessToken, refreshToken) {
20 // NIGDY nie używaj localStorage dla tokenów!
21 sessionStorage.setItem(this.tokenKey, accessToken);
22
23 if (refreshToken) {
24 // Refresh token w bezpiecznym miejscu
25 this.storeRefreshTokenSecurely(refreshToken);
26 }
27 }
28
29 getToken() {
30 return sessionStorage.getItem(this.tokenKey);
31 }
32
33 removeTokens() {
34 sessionStorage.removeItem(this.tokenKey);
35 sessionStorage.removeItem(this.refreshTokenKey);
36
37 // Wyloguj także z cookies
38 fetch('/api/auth/logout', { method: 'POST' });
39 }
40
41 // Walidacja tokenu
42 isTokenValid(token) {
43 if (!token) return false;
44
45 try {
46 // JWT koduje payload w base64url, a atob() oczekuje zwykłego base64
47 const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/');
48 const payload = JSON.parse(atob(base64));
49 const now = Date.now() / 1000;
50
51 // Sprawdź czy token nie wygasł
52 return payload.exp > now;
53 } catch {
54 return false;
55 }
56 }
57
58 // Automatyczne odświeżanie tokenu
59 async refreshTokenIfNeeded() {
60 const token = this.getToken();
61
62 if (!token || !this.isTokenValid(token)) {
63 return await this.refreshToken();
64 }
65
66 return token;
67 }
68
69 async refreshToken() {
70 try {
71 const response = await fetch('/api/auth/refresh', {
72 method: 'POST',
73 credentials: 'include' // Dla httpOnly cookies
74 });
75
76 if (response.ok) {
77 const { accessToken } = await response.json();
78 sessionStorage.setItem(this.tokenKey, accessToken);
79 return accessToken;
80 }
81 } catch (error) {
82 console.error('Token refresh failed:', error);
83 this.removeTokens();
84 }
85
86 return null;
87 }
88}
89
90// Hook dla autoryzacji
91function useAuth() {
92 const [user, setUser] = useState(null);
93 const [loading, setLoading] = useState(true);
94 const tokenManager = useRef(new SecureTokenManager());
95
96 const login = async (credentials) => {
97 try {
98 const response = await fetch('/api/auth/login', {
99 method: 'POST',
100 headers: { 'Content-Type': 'application/json' },
101 body: JSON.stringify(credentials)
102 });
103
104 if (response.ok) {
105 const { user, accessToken, refreshToken } = await response.json();
106
107 // Bezpieczne przechowywanie tokenów
108 tokenManager.current.setTokens(accessToken, refreshToken);
109 setUser(user);
110
111 return { success: true };
112 }
113 } catch (error) {
114 return { success: false, error: error.message };
115 }
116 };
117
118 const logout = () => {
119 tokenManager.current.removeTokens();
120 setUser(null);
121 };
122
123 // Automatyczne odświeżanie tokenu
124 useEffect(() => {
125 const interval = setInterval(async () => {
126 await tokenManager.current.refreshTokenIfNeeded();
127 }, 5 * 60 * 1000); // Co 5 minut
128
129 return () => clearInterval(interval);
130 }, []);
131
132 return { user, login, logout, loading };
133}Najbezpieczniejsze jest ciasteczko httpOnly, którego JavaScript nie odczyta, najlepiej ustawione przez serwer już przy logowaniu. sessionStorage znika po zamknięciu karty, ale przed XSS chroni tak słabo jak localStorage, bo oba odczyta każdy skrypt z tej domeny. isTokenValid sprawdza tylko datę wygaśnięcia, a podpis weryfikuje wyłącznie serwer.
Interceptory Axios
Interceptory dokładają nagłówki do każdego żądania i ponawiają je po odświeżeniu tokenu:
1// Bezpieczna konfiguracja Axios
2const createSecureAxiosInstance = () => {
3 const tokenManager = new SecureTokenManager();
4
5 const axiosInstance = axios.create({
6 baseURL: process.env.REACT_APP_API_URL,
7 timeout: 10000,
8 withCredentials: true // Dla httpOnly cookies
9 });
10
11 // Interceptor żądań
12 axiosInstance.interceptors.request.use(
13 async (config) => {
14 const token = await tokenManager.refreshTokenIfNeeded();
15
16 if (token) {
17 config.headers.Authorization = `Bearer ${token}`;
18 }
19
20 // Dodaj CSRF token jeśli dostępny
21 const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content;
22 if (csrfToken) {
23 config.headers['X-CSRF-Token'] = csrfToken;
24 }
25
26 return config;
27 },
28 (error) => Promise.reject(error)
29 );
30
31 // Interceptor odpowiedzi
32 axiosInstance.interceptors.response.use(
33 (response) => response,
34 async (error) => {
35 const originalRequest = error.config;
36
37 if (error.response?.status === 401 && !originalRequest._retry) {
38 originalRequest._retry = true;
39
40 const newToken = await tokenManager.refreshToken();
41
42 if (newToken) {
43 originalRequest.headers.Authorization = `Bearer ${newToken}`;
44 return axiosInstance(originalRequest);
45 } else {
46 // Przekieruj do logowania
47 window.location.href = '/login';
48 }
49 }
50
51 return Promise.reject(error);
52 }
53 );
54
55 return axiosInstance;
56};Przy statusie 401 interceptor odświeża token i powtarza żądanie tylko raz, czego pilnuje flaga _retry. Nagłówek X-CSRF-Token broni przed CSRF, czyli atakiem, w którym obca strona wysyła żądanie z ciasteczkami ofiary. Taki token musi pochodzić z kryptograficznie bezpiecznego generatora, jak crypto.getRandomValues(), nigdy z Math.random(), a serwer porównuje go z wartością z sesji.
3. Zmienne środowiskowe
Plik .env.example pokazuje zespołowi, jakich zmiennych potrzebuje projekt, bez prawdziwych wartości:
1# .env.example - szablon dla zespołu, bez prawdziwych wartości
2# Sekrety, jak API_SECRET_KEY czy DATABASE_URL, nie trafiają do tego pliku
3# ani do zmiennych z prefiksem REACT_APP_, bo te zobaczy przeglądarka
4REACT_APP_API_URL=https://api.example.com
5REACT_APP_ENVIRONMENT=development
6REACT_APP_SENTRY_DSN=your_sentry_dsn_hereWszystko z prefiksem REACT_APP_ trafia do paczki JavaScript, więc przeczyta to każdy użytkownik, a sekrety zostają na serwerze. Prefiks pochodzi z Create React App, które zespół React wycofał w 2025 roku. W Vite odpowiednikiem jest VITE_, w Next.js NEXT_PUBLIC_, a zasada pozostaje ta sama.
EnvironmentValidator sprawdza przy starcie, czy wymagane zmienne istnieją, i przerywa uruchomienie, gdy czegoś brakuje:
1// Walidacja zmiennych środowiskowych
2class EnvironmentValidator {
3 static requiredVars = [
4 'REACT_APP_API_URL',
5 'REACT_APP_ENVIRONMENT'
6 ];
7
8 static validate() {
9 const missing = this.requiredVars.filter(
10 varName => !process.env[varName]
11 );
12
13 if (missing.length > 0) {
14 throw new Error(
15 `Missing required environment variables: ${missing.join(', ')}`
16 );
17 }
18 }
19
20 static get(key, defaultValue = null) {
21 const value = process.env[key];
22
23 if (!value && defaultValue === null) {
24 console.warn(`Environment variable ${key} is not set`);
25 }
26
27 return value || defaultValue;
28 }
29
30 static getSecure(key) {
31 const value = process.env[key];
32
33 if (!value) {
34 throw new Error(`Secure environment variable ${key} is required`);
35 }
36
37 return value;
38 }
39}
40
41// Konfiguracja aplikacji
42const appConfig = {
43 apiUrl: EnvironmentValidator.getSecure('REACT_APP_API_URL'),
44 environment: EnvironmentValidator.get('REACT_APP_ENVIRONMENT', 'development'),
45 isDevelopment: process.env.NODE_ENV === 'development',
46 isProduction: process.env.NODE_ENV === 'production',
47
48 // Funkcje pomocnicze
49 get isSecure() {
50 return this.apiUrl.startsWith('https://');
51 },
52
53 get shouldEnableDevTools() {
54 return this.isDevelopment && !this.isProduction;
55 }
56};
57
58// Walidacja przy starcie aplikacji
59EnvironmentValidator.validate();Błąd przy starcie jest lepszy niż aplikacja, która po cichu wysyła żądania pod undefined. getSecure rzuca wyjątek, a get tylko ostrzega i zwraca wartość domyślną.
ConfigProvider rozdziela ustawienia według środowiska: debugowanie, poziom logów i mocki:
1// ConfigProvider.jsx
2const ConfigContext = createContext();
3
4export function ConfigProvider({ children }) {
5 const config = useMemo(() => {
6 const baseConfig = {
7 apiUrl: process.env.REACT_APP_API_URL,
8 environment: process.env.REACT_APP_ENVIRONMENT,
9 version: process.env.REACT_APP_VERSION || '1.0.0'
10 };
11
12 // Konfiguracja specyficzna dla środowiska
13 const environmentConfigs = {
14 development: {
15 debug: true,
16 logLevel: 'debug',
17 enableMocks: true
18 },
19 staging: {
20 debug: true,
21 logLevel: 'info',
22 enableMocks: false
23 },
24 production: {
25 debug: false,
26 logLevel: 'error',
27 enableMocks: false
28 }
29 };
30
31 return {
32 ...baseConfig,
33 ...environmentConfigs[baseConfig.environment] || environmentConfigs.production
34 };
35 }, []);
36
37 return (
38 <ConfigContext.Provider value={config}>
39 {children}
40 </ConfigContext.Provider>
41 );
42}
43
44export const useConfig = () => {
45 const context = useContext(ConfigContext);
46 if (!context) {
47 throw new Error('useConfig must be used within ConfigProvider');
48 }
49 return context;
50};Nieznane środowisko dostaje konfigurację production, czyli najbezpieczniejszą: bez debugowania i bez mocków.
4. Skanowanie zależności
Najsłabszym ogniwem bywa cudzy kod. Skrypty w package.json uruchamiają audyt podatności i kontrolę licencji:
1// Skrypty bezpieczeństwa w package.json
2{
3 "scripts": {
4 "audit": "npm audit",
5 "audit:fix": "npm audit fix",
6 "audit:force": "npm audit fix --force",
7 "security:check": "npm audit && npm run license:check",
8 "license:check": "license-checker --summary",
9 "deps:update": "npm-check-updates -u",
10 "deps:check": "npm outdated"
11 },
12 "devDependencies": {
13 "license-checker": "^25.0.1",
14 "npm-check-updates": "^23.1.0",
15 "audit-ci": "^7.1.0"
16 }
17}npm audit porównuje zależności z bazą znanych podatności. Uważaj na npm audit fix --force, bo może podnieść wersje major i zepsuć aplikację.
Te same kontrole warto uruchamiać automatycznie przy każdym pushu i pull requeście:
1# .github/workflows/security.yml
2name: Security Checks
3
4on: [push, pull_request]
5
6jobs:
7 security:
8 runs-on: ubuntu-latest
9 permissions:
10 contents: read
11 security-events: write # CodeQL wysyła wyniki do zakładki Security
12
13 steps:
14 - uses: actions/checkout@v7
15
16 - name: Initialize CodeQL
17 uses: github/codeql-action/init@v4
18 with:
19 languages: javascript-typescript
20
21 - name: Setup Node.js
22 uses: actions/setup-node@v7
23 with:
24 node-version: 'lts/*'
25 cache: 'npm'
26
27 - name: Install dependencies
28 run: npm ci
29
30 - name: Run npm audit
31 run: npm audit --audit-level high
32
33 - name: Check for known vulnerabilities
34 run: npx audit-ci --high
35
36 - name: License compliance check
37 run: npm run license:check
38
39 - name: SAST with CodeQL
40 uses: github/codeql-action/analyze@v4
41
42 - name: Dependency Review
43 uses: actions/dependency-review-action@v5
44 if: github.event_name == 'pull_request'Pipeline zatrzyma się na podatności o poziomie high, a dependency-review-action sprawdzi nowe zależności w pull requeście. CodeQL działa w dwóch krokach: init przygotowuje analizę, a analyze skanuje kod i wysyła wyniki, do czego job potrzebuje uprawnienia security-events: write. node-version: 'lts/*' wybiera aktualną wersję LTS Node.js, a numery wersji akcji może podbijać Dependabot (package-ecosystem: github-actions).
SecurityMonitor nasłuchuje w przeglądarce zdarzeń securitypolicyviolation, czyli naruszeń CSP, i raportuje je do serwera:
1// SecurityMonitor.jsx
2class SecurityMonitor {
3 constructor() {
4 this.violations = [];
5 this.init();
6 }
7
8 init() {
9 // Monitorowanie CSP violations
10 document.addEventListener('securitypolicyviolation', (e) => {
11 this.reportViolation({
12 type: 'CSP_VIOLATION',
13 directive: e.violatedDirective,
14 blockedURI: e.blockedURI,
15 documentURI: e.documentURI,
16 timestamp: new Date().toISOString()
17 });
18 });
19
20 // Monitorowanie console.error dla potencjalnych problemów bezpieczeństwa
21 const originalError = console.error;
22 console.error = (...args) => {
23 const message = args.join(' ');
24
25 if (this.isSecurityRelated(message)) {
26 this.reportViolation({
27 type: 'SECURITY_ERROR',
28 message,
29 timestamp: new Date().toISOString()
30 });
31 }
32
33 originalError.apply(console, args);
34 };
35 }
36
37 isSecurityRelated(message) {
38 const securityKeywords = [
39 'unsafe-inline',
40 'unsafe-eval',
41 'xss',
42 'csrf',
43 'cors',
44 'unauthorized',
45 'forbidden'
46 ];
47
48 return securityKeywords.some(keyword =>
49 message.toLowerCase().includes(keyword)
50 );
51 }
52
53 reportViolation(violation) {
54 this.violations.push(violation);
55
56 // Wyślij do systemu monitorowania
57 if (process.env.NODE_ENV === 'production') {
58 fetch('/api/security/violations', {
59 method: 'POST',
60 headers: { 'Content-Type': 'application/json' },
61 body: JSON.stringify(violation)
62 }).catch(error => {
63 console.error('Failed to report security violation:', error);
64 });
65 }
66 }
67
68 getViolations() {
69 return [...this.violations];
70 }
71}
72
73// Hook dla monitorowania bezpieczeństwa
74export function useSecurityMonitor() {
75 const monitor = useRef(new SecurityMonitor());
76
77 const reportCustomViolation = useCallback((violation) => {
78 monitor.current.reportViolation({
79 type: 'CUSTOM_VIOLATION',
80 ...violation,
81 timestamp: new Date().toISOString()
82 });
83 }, []);
84
85 return {
86 reportViolation: reportCustomViolation,
87 getViolations: () => monitor.current.getViolations()
88 };
89}O zablokowanym skrypcie dowiesz się, zanim zgłosi go użytkownik. Nadpisywanie console.error to jednak ingerencja globalna, więc stosuj ją ostrożnie.
5. Walidacja i sanityzacja danych
InputValidator sprawdza wymagane pola, długość i wzorce, a SecureForm waliduje pola przy wpisywaniu i czyści wartości przed wysłaniem:
1// InputValidator.js
2class InputValidator {
3 static patterns = {
4 email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/,
5 phone: /^\+?[\d\s\-\(\)]+$/,
6 url: /^https?:\/\/.+/,
7 alphanumeric: /^[a-zA-Z0-9]+$/,
8 noSpecialChars: /^[a-zA-Z0-9\s\-_]+$/
9 };
10
11 static validate(value, rules) {
12 const errors = [];
13
14 // Pole wymagane
15 if (rules.required && (!value || value.trim() === '')) {
16 errors.push('To pole jest wymagane');
17 }
18
19 if (!value) return errors;
20
21 // Długość
22 if (rules.minLength && value.length < rules.minLength) {
23 errors.push(`Minimalna liczba znaków: ${rules.minLength}`);
24 }
25
26 if (rules.maxLength && value.length > rules.maxLength) {
27 errors.push(`Maksymalna liczba znaków: ${rules.maxLength}`);
28 }
29
30 // Wzorzec
31 if (rules.pattern && !this.patterns[rules.pattern]?.test(value)) {
32 errors.push(`Nieprawidłowy format: ${rules.pattern}`);
33 }
34
35 // Własna reguła
36 if (rules.custom) {
37 const customError = rules.custom(value);
38 if (customError) errors.push(customError);
39 }
40
41 // Ochrona przed XSS
42 if (rules.preventXSS && this.containsPotentialXSS(value)) {
43 errors.push('Pole zawiera potencjalnie niebezpieczną treść');
44 }
45
46 return errors;
47 }
48
49 static containsPotentialXSS(value) {
50 const xssPatterns = [
51 /<script[^>]*>.*?<\/script>/gi,
52 /javascript:/gi,
53 /on\w+\s*=/gi,
54 /<iframe[^>]*>/gi,
55 /<object[^>]*>/gi,
56 /<embed[^>]*>/gi,
57 /eval\s*\(/gi,
58 /expression\s*\(/gi
59 ];
60
61 return xssPatterns.some(pattern => pattern.test(value));
62 }
63
64 static sanitize(value, options = {}) {
65 if (!value) return value;
66
67 let sanitized = value;
68
69 // Usuń znaczniki HTML
70 if (options.stripHTML) {
71 sanitized = sanitized.replace(/<[^>]*>/g, '');
72 }
73
74 // Zamień znaki specjalne na encje HTML
75 if (options.escapeHTML) {
76 sanitized = sanitized
77 .replace(/&/g, '&')
78 .replace(/</g, '<')
79 .replace(/>/g, '>')
80 .replace(/"/g, '"')
81 .replace(/'/g, ''');
82 }
83
84 // Usuń bajty zerowe
85 sanitized = sanitized.replace(/\0/g, '');
86
87 // Usuń białe znaki z początku i końca
88 if (options.trim !== false) {
89 sanitized = sanitized.trim();
90 }
91
92 return sanitized;
93 }
94}
95
96// Komponent SecureForm
97function SecureForm({ onSubmit, children }) {
98 const [formData, setFormData] = useState({});
99 const [errors, setErrors] = useState({});
100 const [isSubmitting, setIsSubmitting] = useState(false);
101
102 const validateField = useCallback((name, value, rules) => {
103 const fieldErrors = InputValidator.validate(value, rules);
104
105 setErrors(prev => ({
106 ...prev,
107 [name]: fieldErrors.length > 0 ? fieldErrors : undefined
108 }));
109
110 return fieldErrors.length === 0;
111 }, []);
112
113 const handleInputChange = useCallback((name, value, rules) => {
114 // Trzymaj surowy tekst: React escapuje go przy renderowaniu
115 setFormData(prev => ({
116 ...prev,
117 [name]: value
118 }));
119
120 // Waliduj przy każdej zmianie
121 validateField(name, value, rules);
122 }, [validateField]);
123
124 const handleSubmit = async (e) => {
125 e.preventDefault();
126 setIsSubmitting(true);
127
128 // Waliduj wszystkie pola według reguł zapisanych w dzieciach
129 let isValid = true;
130 const newErrors = {};
131
132 React.Children.forEach(children, child => {
133 if (!React.isValidElement(child) || !child.props.name) return;
134
135 const fieldName = child.props.name;
136 const fieldErrors = InputValidator.validate(formData[fieldName], child.props.validationRules || {});
137
138 if (fieldErrors.length > 0) {
139 newErrors[fieldName] = fieldErrors;
140 isValid = false;
141 }
142 });
143
144 setErrors(newErrors);
145
146 if (isValid) {
147 // Przytnij wartości i usuń bajty zerowe dopiero przed wysłaniem
148 const cleanData = Object.fromEntries(
149 Object.entries(formData).map(([key, value]) => [key, InputValidator.sanitize(value)])
150 );
151
152 try {
153 await onSubmit(cleanData);
154 } catch (error) {
155 console.error('Nie udało się wysłać formularza:', error);
156 }
157 }
158
159 setIsSubmitting(false);
160 };
161
162 return (
163 <form onSubmit={handleSubmit}>
164 {React.Children.map(children, child => {
165 if (React.isValidElement(child) && child.props.name) {
166 return React.cloneElement(child, {
167 value: formData[child.props.name] || '',
168 onChange: (e) => handleInputChange(
169 child.props.name,
170 e.target.value,
171 child.props.validationRules
172 ),
173 error: errors[child.props.name],
174 disabled: isSubmitting
175 });
176 }
177 return child;
178 })}
179 </form>
180 );
181}SecureForm trzyma w stanie surowy tekst, bo React escapuje go przy renderowaniu, a escapowanie encji przy każdym naciśnięciu klawisza psułoby wpisywany tekst: & zamieniałby się w &, a po następnym znaku w &amp;. sanitize przycina wartości dopiero przed wysłaniem, a escapeHTML przydaje się tylko wtedy, gdy tekst trafia do HTML poza Reactem. Walidacja w przeglądarce poprawia wygodę, ale nie zabezpiecza, bo atakujący wyśle żądanie z pominięciem formularza. Serwer musi sprawdzić dane ponownie, a wyrażenia z containsPotentialXSS traktuj jako sygnał, nie tarczę.
6. OWASP Top 10
Na koniec lista kontrolna według OWASP Top 10:2025, najpopularniejszego zestawienia ryzyk aplikacji webowych. Kod pokazuje wybrane kategorie w kolejności z rankingu:
1// Lista kontrolna OWASP Top 10:2025 (wybrane kategorie)
2
3// A01:2025 Broken Access Control - UI ukrywa widoki, ale uprawnienia sprawdza serwer
4function ProtectedRoute({ children, requiredRole, requiredPermissions }) {
5 const { user, hasRole, hasPermissions } = useAuth();
6
7 if (!user) {
8 return <Navigate to="/login" replace />;
9 }
10
11 if (requiredRole && !hasRole(requiredRole)) {
12 return <div>Odmowa dostępu: niewystarczająca rola</div>;
13 }
14
15 if (requiredPermissions && !hasPermissions(requiredPermissions)) {
16 return <div>Odmowa dostępu: brak uprawnień</div>;
17 }
18
19 return children;
20}
21
22// A02:2025 Security Misconfiguration - nagłówki, CORS i parser XML (XXE)
23const SecurityConfig = {
24 securityHeaders: {
25 'Strict-Transport-Security': 'max-age=31536000; includeSubDomains',
26 'X-Content-Type-Options': 'nosniff',
27 'X-Frame-Options': 'DENY',
28 'X-XSS-Protection': '0',
29 'Referrer-Policy': 'strict-origin-when-cross-origin'
30 },
31
32 corsConfig: {
33 origin: process.env.ALLOWED_ORIGINS?.split(',') || [],
34 credentials: true,
35 optionsSuccessStatus: 200
36 },
37
38 // DOMParser w przeglądarce nie pobiera zewnętrznych encji XML
39 parseXMLSafely: (xmlString) => {
40 const doc = new DOMParser().parseFromString(xmlString, 'text/xml');
41
42 if (doc.querySelector('parsererror')) {
43 throw new Error('Nieprawidłowy format XML');
44 }
45
46 return doc;
47 }
48};
49
50// A03:2025 Software Supply Chain Failures - npm ci, npm audit i dependency review z sekcji 4
51
52// A04:2025 Cryptographic Failures - losowość z Web Crypto, szyfrowanie danych na serwerze
53const CryptoPractices = {
54 // Token z kryptograficznie bezpiecznego generatora, nigdy z Math.random()
55 randomToken: (bytes = 32) => {
56 const values = crypto.getRandomValues(new Uint8Array(bytes));
57 return Array.from(values, (b) => b.toString(16).padStart(2, '0')).join('');
58 }
59};
60
61// A05:2025 Injection - dane osobno od zapytania, nigdy sklejane w tekst SQL
62const InjectionPrevention = {
63 // Zawsze używaj prepared statements (kod backendu)
64 safeQuery: (query, params) => {
65 return db.prepare(query).run(params);
66 },
67
68 // Walidacja parametrów
69 validateParams: (params, schema) => {
70 return schema.validate(params);
71 }
72};
73
74// A07:2025 Authentication Failures - zasady haseł według NIST SP 800-63B
75const AuthenticationSecurity = {
76 passwordPolicy: {
77 minLength: 15, // 8, jeśli konto ma MFA
78 maxLength: 64, // pozwól na długie frazy
79 checkBreachedPasswords: true, // odrzucaj hasła z wycieków i najpopularniejsze
80 requireCharacterMix: false, // bez wymuszania wielkich liter i znaków specjalnych
81 forcePeriodicChange: false // zmiana hasła tylko po podejrzeniu wycieku
82 },
83
84 // Ograniczenie nieudanych prób logowania
85 loginAttempts: {
86 maxAttempts: 5,
87 lockoutDuration: 15 * 60 * 1000 // 15 minut
88 },
89
90 // Sesja w ciasteczku
91 sessionConfig: {
92 httpOnly: true,
93 secure: true,
94 sameSite: 'strict',
95 maxAge: 30 * 60 * 1000 // 30 minut
96 }
97};Pełna lista OWASP Top 10:2025 to A01 Broken Access Control, A02 Security Misconfiguration, A03 Software Supply Chain Failures, A04 Cryptographic Failures, A05 Injection, A06 Insecure Design, A07 Authentication Failures, A08 Software or Data Integrity Failures, A09 Security Logging and Alerting Failures i A10 Mishandling of Exceptional Conditions. XSS z pierwszej sekcji należy do A05, a SecurityMonitor z sekcji o zależnościach to przykład A09. X-XSS-Protection ustawiamy na 0, bo przestarzały filtr przeglądarek potrafił tworzyć nowe luki, a MDN zaleca zamiast niego CSP. Klucz szyfrujący w kodzie frontendu nie jest tajemnicą, więc szyfrowanie danych wrażliwych należy do serwera, a przeglądarka bierze losowe wartości z Web Crypto API. Zasady haseł idą za NIST SP 800-63B (wersja 4 z 2025 roku), na który powołuje się OWASP: liczy się długość i odrzucanie haseł z wycieków, a wymuszania znaków specjalnych i okresowej zmiany hasła NIST zabrania.
Bezpieczeństwo obejmuje wszystkie warstwy, od klienta po serwer, i wymaga regularnych audytów oraz aktualnych zależności. Polecam zacząć od trzech kroków: DOMPurify przy każdym surowym HTML, ciasteczka httpOnly i automatyczny audyt w CI. Wszystkie wrócą w ćwiczeniu Security Operations Center.
Pamiętaj: każda warstwa to osobna tarcza statku - React escapuje, CSP filtruje, a serwer sprawdza wszystko jeszcze raz.
Kod do tej lekcji: App.jsx
1import React, { useState, useRef } from 'react';
2
3// Demonstracja praktyk bezpieczeństwa w React
4// Ochrona przed XSS, sanityzacja HTML i walidacja danych wejściowych
5
6function XSSDemo() {
7 const [userInput, setUserInput] = useState('');
8 const [messages, setMessages] = useState([
9 { id: 1, text: 'Witaj na pokładzie stacji!', author: 'Kapitan' },
10 { id: 2, text: 'Systemy obronne aktywne.', author: 'Oficer' },
11 ]);
12 const nextId = useRef(3);
13
14 // React automatycznie escapuje zawartość JSX - ochrona przed XSS
15 const maliciousInput = '<script>alert("hack!")<\/script><img src=x onerror=alert(1)>';
16
17 const addMessage = () => {
18 if (userInput.trim()) {
19 setMessages(prev => [...prev, {
20 id: nextId.current++,
21 text: userInput, // React bezpiecznie escapuje te dane
22 author: 'Użytkownik',
23 }]);
24 setUserInput('');
25 }
26 };
27
28 return (
29 <div style={styles.panel}>
30 <h2 style={styles.heading}>1. Ochrona przed XSS</h2>
31
32 <div style={{ marginBottom: '16px' }}>
33 <h4 style={styles.subheading}>Złośliwe dane wejściowe (automatycznie escapowane):</h4>
34 <div style={{ background: '#0d1b2a', padding: '12px', borderRadius: '8px', color: '#ff6b6b', fontFamily: 'monospace', fontSize: '13px', wordBreak: 'break-all' }}>
35 {maliciousInput}
36 </div>
37 <p style={{ color: '#00ff88', fontSize: '13px' }}>
38 React renderuje to jako tekst, nie jako HTML - skrypt się nie wykona!
39 </p>
40 </div>
41
42 <div>
43 <h4 style={styles.subheading}>Bezpieczny czat stacji kosmicznej:</h4>
44 <div style={{ background: '#0d1b2a', padding: '12px', borderRadius: '8px', maxHeight: '150px', overflowY: 'auto', marginBottom: '8px' }}>
45 {messages.map(msg => (
46 <div key={msg.id} style={{ marginBottom: '8px' }}>
47 <span style={{ color: '#00d4ff', fontWeight: 'bold' }}>{msg.author}: </span>
48 <span style={{ color: '#e0e1dd' }}>{msg.text}</span>
49 </div>
50 ))}
51 </div>
52 <div style={{ display: 'flex', gap: '8px' }}>
53 <input
54 value={userInput}
55 onChange={e => setUserInput(e.target.value)}
56 onKeyDown={e => e.key === 'Enter' && addMessage()}
57 placeholder="Wpisz wiadomość (spróbuj HTML/script)..."
58 aria-label="Wpisz wiadomość (spróbuj HTML/script)..."
59 style={{ flex: 1, minWidth: 0, padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd' }}
60 />
61 <button onClick={addMessage} style={{ padding: '8px 16px', borderRadius: '8px', border: 'none', background: '#00d4ff', color: '#0d1b2a', fontWeight: 'bold', cursor: 'pointer' }}>
62 Wyślij
63 </button>
64 </div>
65 </div>
66 </div>
67 );
68}
69
70function DangerousHTMLDemo() {
71 const [showComparison, setShowComparison] = useState(false);
72
73 const safeHTML = '<strong>Ważna wiadomość</strong> ze stacji <em>kosmicznej</em>';
74 const unsafeHTML = '<img src=x onerror="alert(1)"><b>Niebezpieczny HTML</b>';
75
76 // Uproszczony filtr: zostawia kilka znaczników bez żadnych atrybutów (także onerror, onclick)
77 // i usuwa resztę. Tylko do demonstracji - w aplikacji użyj DOMPurify.sanitize()
78 const sanitize = (html) => {
79 const allowedTags = ['b', 'i', 'em', 'strong', 'p', 'br', 'span'];
80 return html.replace(/<(\/?)([a-zA-Z]+)[^>]*>/g, (match, slash, tag) =>
81 allowedTags.includes(tag.toLowerCase()) ? `<${slash}${tag.toLowerCase()}>` : ''
82 );
83 };
84
85 return (
86 <div style={{ ...styles.panel, marginTop: '16px' }}>
87 <h2 style={styles.heading}>2. dangerouslySetInnerHTML</h2>
88
89 <div style={{ display: 'grid', gap: '12px' }}>
90 <div style={styles.box}>
91 <h4 style={{ color: '#ff6b6b', margin: '0 0 8px' }}>Niebezpieczne użycie:</h4>
92 <code style={{ color: '#778da9', fontSize: '13px' }}>
93 {'dangerouslySetInnerHTML={{ __html: userInput }}'}
94 </code>
95 <p style={{ color: '#ff6b6b', fontSize: '13px', margin: '8px 0 0' }}>
96 Nigdy nie używaj z niezaufanymi danymi!
97 </p>
98 </div>
99
100 <div style={styles.box}>
101 <h4 style={{ color: '#00ff88', margin: '0 0 8px' }}>Bezpieczne - po sanityzacji:</h4>
102 <div style={{ color: '#e0e1dd', marginBottom: '8px' }} dangerouslySetInnerHTML={{ __html: sanitize(safeHTML) }} />
103 <code style={{ color: '#778da9', fontSize: '12px', display: 'block' }}>
104 {'dangerouslySetInnerHTML={{ __html: sanitize(html) }}'}
105 </code>
106 <p style={{ color: '#ffd166', fontSize: '12px', margin: '8px 0 0' }}>
107 Funkcja sanitize w tym przykładzie to uproszczenie. W prawdziwej aplikacji użyj DOMPurify.sanitize(), który zna pułapki parsera HTML.
108 </p>
109 </div>
110 </div>
111
112 <button
113 onClick={() => setShowComparison(visible => !visible)}
114 style={{ marginTop: '12px', padding: '8px 16px', borderRadius: '8px', border: '1px solid #415a77', background: 'transparent', color: '#778da9', cursor: 'pointer' }}
115 >
116 {showComparison ? 'Ukryj porównanie sanityzacji' : 'Pokaż porównanie sanityzacji'}
117 </button>
118
119 {showComparison && (
120 <div style={{ ...styles.box, marginTop: '12px' }}>
121 <p style={{ color: '#778da9', margin: '0 0 4px', fontSize: '13px', wordBreak: 'break-all' }}>Przed: {unsafeHTML}</p>
122 <p style={{ color: '#00ff88', margin: 0, fontSize: '13px', wordBreak: 'break-all' }}>Po sanityzacji: {sanitize(unsafeHTML)}</p>
123 </div>
124 )}
125 </div>
126 );
127}
128
129// Siła hasła według NIST SP 800-63B: liczy się długość i odrzucanie popularnych haseł, a nie wymuszone znaki specjalne
130const COMMON_PASSWORDS = ['123456', 'password', 'qwerty', 'haslo123', 'admin123'];
131
132function passwordStrength(password) {
133 if (COMMON_PASSWORDS.includes(password.toLowerCase())) return 0;
134 if (password.length >= 20) return 4;
135 if (password.length >= 15) return 3;
136 if (password.length >= 12) return 2;
137 if (password.length >= 8) return 1;
138 return 0;
139}
140
141function InputValidationDemo() {
142 const [email, setEmail] = useState('');
143 const [emailValid, setEmailValid] = useState(null);
144 const [password, setPassword] = useState('');
145
146 const validateEmail = (value) => {
147 setEmail(value);
148 if (!value) { setEmailValid(null); return; }
149 const regex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/;
150 setEmailValid(regex.test(value));
151 };
152
153 const strength = passwordStrength(password);
154 const isCommon = COMMON_PASSWORDS.includes(password.toLowerCase());
155 const strengthLabels = ['Bardzo słabe', 'Słabe', 'Przeciętne', 'Dobre', 'Silne'];
156 const strengthColors = ['#ff6b6b', '#ff6b6b', '#ffaa00', '#00d4ff', '#00ff88'];
157
158 return (
159 <div style={{ ...styles.panel, marginTop: '16px' }}>
160 <h2 style={styles.heading}>3. Walidacja danych wejściowych</h2>
161
162 <div style={{ marginBottom: '16px' }}>
163 <label htmlFor="crew-email" style={styles.label}>Email załogi:</label>
164 <input
165 id="crew-email"
166 value={email}
167 onChange={e => validateEmail(e.target.value)}
168 placeholder="pilot@stacja.space"
169 style={{ ...styles.input, borderColor: emailValid === null ? '#415a77' : emailValid ? '#00ff88' : '#ff6b6b' }}
170 />
171 {emailValid !== null && (
172 <p style={{ color: emailValid ? '#00ff88' : '#ff6b6b', fontSize: '13px', margin: '4px 0 0' }}>
173 {emailValid ? 'Poprawny format email' : 'Niepoprawny format email'}
174 </p>
175 )}
176 </div>
177
178 <div>
179 <label htmlFor="crew-password" style={styles.label}>Hasło dostępu:</label>
180 <input
181 id="crew-password"
182 type="password"
183 value={password}
184 onChange={e => setPassword(e.target.value)}
185 placeholder="Min. 15 znaków, najlepiej kilka słów"
186 style={styles.input}
187 />
188 {password && (
189 <div style={{ marginTop: '8px' }}>
190 <div style={{ display: 'flex', gap: '4px', marginBottom: '4px' }}>
191 {[1, 2, 3, 4].map(level => (
192 <div key={level} style={{ flex: 1, height: '4px', borderRadius: '2px', background: level <= strength ? strengthColors[strength] : '#415a77' }} />
193 ))}
194 </div>
195 <p style={{ color: strengthColors[strength], fontSize: '13px', margin: 0 }}>
196 {isCommon ? 'To hasło jest na liście najpopularniejszych - wybierz inne' : `Siła hasła: ${strengthLabels[strength]} (długość: ${password.length})`}
197 </p>
198 </div>
199 )}
200 <p style={{ color: '#778da9', fontSize: '12px', margin: '8px 0 0' }}>Długa fraza z kilku słów jest mocniejsza niż krótkie hasło ze znakami specjalnymi.</p>
201 </div>
202 </div>
203 );
204}
205
206const styles = {
207 panel: { background: '#1b2838', padding: '20px', borderRadius: '12px', border: '1px solid #415a77' },
208 heading: { color: '#00d4ff', marginTop: 0 },
209 subheading: { color: '#778da9' },
210 box: { background: '#0d1b2a', padding: '12px', borderRadius: '8px' },
211 label: { color: '#778da9', display: 'block', marginBottom: '4px' },
212 input: { width: '100%', padding: '8px 12px', borderRadius: '8px', border: '1px solid #415a77', background: '#0d1b2a', color: '#e0e1dd', boxSizing: 'border-box' },
213};
214
215export default function App() {
216 return (
217 <div style={{ background: '#0d1b2a', minHeight: '100vh', padding: '24px 16px', color: '#e0e1dd', fontFamily: 'monospace' }}>
218 <div style={{ maxWidth: '650px', margin: '0 auto' }}>
219 <h1 style={{ color: '#00d4ff', textAlign: 'center' }}>Bezpieczeństwo w React</h1>
220 <p style={{ color: '#778da9', textAlign: 'center', marginBottom: '24px' }}>
221 Systemy obronne stacji kosmicznej - praktyki bezpieczeństwa
222 </p>
223 <XSSDemo />
224 <DangerousHTMLDemo />
225 <InputValidationDemo />
226 </div>
227 </div>
228 );
229}Widzisz błąd w tej lekcji?
Sprawdź się
Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.
1. Jak React domyślnie chroni przed atakami XSS (Cross-Site Scripting)?
2. Kiedy w React TRZEBA sanityzować dane wejściowe użytkownika za pomocą biblioteki jak DOMPurify?
Zadania praktyczne w grze
- Układanie w poziomie
Ułóż składnię użycia DOMPurify do bezpiecznego renderowania HTML w React:
- Edytor kodu
Zabezpiecz panel transmisji od obcych statków. Treść wiadomości React escapuje sam, bo trafia do JSX w klamrach. ___BLANK1___: isSafeUrl zwraca true tylko dla protokołów http:, https: i mailto: (dla javascript:, data: i każdego innego - false). ___BLANK2___: wypełnij tablicę bytes losowymi bajtami z kryptograficznie bezpiecznego crypto.getRandomValues (nigdy Math.random), żeby powstał 32-znakowy token CSRF. ___BLANK3___: link otwierany w nowej karcie dostaje rel z wartościami noopener i noreferrer. W podglądzie link od nieznanego statku musi być zablokowany, a jego tekst widoczny jako zwykły tekst.