Kurs JavaScript i React · Moduł 10: Ekosystem i przyszłość React

Kompleksowe praktyki bezpieczeństwa w React

17 min czytania
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_here

Wszystko 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, '&amp;')
78        .replace(/</g, '&lt;')
79        .replace(/>/g, '&gt;')
80        .replace(/"/g, '&quot;')
81        .replace(/'/g, '&#x27;');
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 &amp;, a po następnym znaku w &amp;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. 1. Jak React domyślnie chroni przed atakami XSS (Cross-Site Scripting)?

  2. 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.

Przydatne artykuły