Tạo middleware chống CSRF và XSS từ zero (PHP thuần, có ví dụ đầy đủ)

Bài này hướng dẫn bạn tự viết middleware chống CSRFXSS cho ứng dụng PHP thuần, không phụ thuộc framework. Ta sẽ dựng kiến trúc tối giản gồm router, middleware, và các helper: sinh token an toàn, gắn vào form/Fetch API, xác thực ở server, bật CSP kèm nonce, và auto-escape output. Tất cả đều sẵn sàng copy–paste và dùng ngay.

Tạo middleware chống CSRF và XSS từ zero (PHP thuần, có ví dụ đầy đủ)

Kiến thức nền tảng

  • CSRF: kẻ tấn công lợi dụng cookie đăng nhập của nạn nhân để gửi yêu cầu trái phép. Giải pháp chuẩn là synchronizer token: server phát sinh token ngẫu nhiên, gắn vào form hoặc header; server chỉ chấp nhận request có token hợp lệ.
  • XSS: chèn script độc hại vào DOM hoặc HTML trả về. Cốt lõi là escape output theo ngữ cảnh và áp Content-Security-Policy hạn chế nguồn script.

Cấu trúc dự án mẫu

.
├─ public/
│  ├─ index.php        // bootstrap + router + middleware
│  └─ form.php         // view demo (GET)
└─ src/
   ├─ Middleware/
   │  ├─ CsrfMiddleware.php
   │  └─ SecurityHeadersMiddleware.php
   ├─ Support/
   │  ├─ Csrf.php
   │  └─ Html.php
   └─ Http/
      ├─ Request.php
      └─ Response.php

Http Request/Response siêu gọn

<?php
// src/Http/Request.php
namespace App\Http;

class Request {
    public string $method;
    public string $uri;
    public array $get;
    public array $post;
    public array $server;
    public array $cookies;
    public array $headers;

    public function __construct() {
        $this->method  = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
        $this->uri     = strtok($_SERVER['REQUEST_URI'] ?? '/', '?');
        $this->get     = $_GET;
        $this->post    = $_POST;
        $this->server  = $_SERVER;
        $this->cookies = $_COOKIE;
        $this->headers = function_exists('getallheaders') ? (getallheaders() ?: []) : [];
    }

    public function input(string $key, $default = null) {
        return $this->post[$key] ?? $this->get[$key] ?? $default;
    }

    public function header(string $key, $default = null) {
        foreach ($this->headers as $k => $v) {
            if (strcasecmp($k, $key) === 0) return $v;
        }
        return $default;
    }

    public function isSafeMethod(): bool {
        return in_array($this->method, ['GET','HEAD','OPTIONS'], true);
    }
}
<?php
// src/Http/Response.php
namespace App\Http;

class Response {
    private int $status = 200;
    private array $headers = [];
    private string $body = '';

    public function status(int $code): self { $this->status = $code; return $this; }
    public function header(string $name, string $value): self { $this->headers[$name] = $value; return $this; }
    public function write(string $content): self { $this->body .= $content; return $this; }

    public function send(): void {
        http_response_code($this->status);
        foreach ($this->headers as $k => $v) header("$k: $v");
        echo $this->body;
    }
}

Helper CSRF: sinh, lấy, verify token

<?php
// src/Support/Csrf.php
namespace App\Support;

final class Csrf {
    public const SESSION_KEY = '_csrf_token';
    public const FORM_KEY    = '_csrf';

    public static function ensureSessionStarted(): void {
        if (session_status() !== PHP_SESSION_ACTIVE) {
            session_start([
                'cookie_httponly' => true,
                'cookie_samesite' => 'Strict',
                'cookie_secure'   => isset($_SERVER['HTTPS']),
            ]);
        }
    }

    public static function token(): string {
        self::ensureSessionStarted();
        if (empty($_SESSION[self::SESSION_KEY])) {
            $_SESSION[self::SESSION_KEY] = self::generate();
        }
        return $_SESSION[self::SESSION_KEY];
    }

    private static function generate(): string {
        return rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '=');
    }

    public static function validate(?string $provided): bool {
        self::ensureSessionStarted();
        $stored = $_SESSION[self::SESSION_KEY] ?? '';
        return is_string($provided) && is_string($stored) && hash_equals($stored, $provided);
    }

    public static function rotate(): void {
        // tùy chọn: xoay token sau khi verify thành công để giảm rủi ro replay
        $_SESSION[self::SESSION_KEY] = self::generate();
    }
}

Helper HTML: auto-escape và in input ẩn CSRF

<?php
// src/Support/Html.php
namespace App\Support;

final class Html {
    public static function e($value): string {
        return htmlspecialchars((string)$value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8', false);
    }

    public static function csrfInput(): string {
        $t = Csrf::token();
        return '<input type="hidden" name="'.self::e(Csrf::FORM_KEY).'" value="'.self::e($t).'">';
    }
}

Middleware CSRF: chỉ kiểm với phương thức thay đổi trạng thái

<?php
// src/Middleware/CsrfMiddleware.php
namespace App\Middleware;

use App\Http\Request;
use App\Http\Response;
use App\Support\Csrf;

final class CsrfMiddleware {
    public function __invoke(Request $req, Response $res, callable $next): Response {
        if ($req->isSafeMethod()) {
            // Tạo sẵn token cho view
            Csrf::token();
            return $next($req, $res);
        }

        // Lấy token từ form hoặc header
        $token = $req->input(\App\Support\Csrf::FORM_KEY)
              ?: $req->header('X-CSRF-Token');

        if (!Csrf::validate($token)) {
            return $res->status(403)
                      ->header('Content-Type', 'text/plain; charset=utf-8')
                      ->write('Forbidden: CSRF verification failed');
        }

        // tùy chọn: xoay token sau khi verify
        Csrf::rotate();
        return $next($req, $res);
    }
}

Middleware Security Headers + CSP nonce

<?php
// src/Middleware/SecurityHeadersMiddleware.php
namespace App\Middleware;

use App\Http\Request;
use App\Http\Response;

final class SecurityHeadersMiddleware {
    public static string $nonce;

    public function __invoke(Request $req, Response $res, callable $next): Response {
        // Nonce cho script an toàn
        self::$nonce = rtrim(strtr(base64_encode(random_bytes(16)), '+/', '-_'), '=');

        $res = $next($req, $res);

        $csp = [
            "default-src 'self'",
            "script-src 'self' 'nonce-".self::$nonce."'",
            "style-src 'self' 'unsafe-inline'",
            "img-src 'self' data:",
            "base-uri 'self'",
            "object-src 'none'",
            "frame-ancestors 'self'",
            "upgrade-insecure-requests"
        ];

        return $res
            ->header('Content-Security-Policy', implode('; ', $csp))
            ->header('X-Content-Type-Options', 'nosniff')
            ->header('X-Frame-Options', 'SAMEORIGIN')
            ->header('Referrer-Policy', 'strict-origin-when-cross-origin')
            ->header('X-XSS-Protection', '0');
    }

    public static function nonceAttr(): string {
        return " nonce=\"".self::$nonce."\"";
    }
}

Bootstrap mini-router và gắn middleware

<?php
// public/index.php
require __DIR__.'/../src/Http/Request.php';
require __DIR__.'/../src/Http/Response.php';
require __DIR__.'/../src/Support/Csrf.php';
require __DIR__.'/../src/Support/Html.php';
require __DIR__.'/../src/Middleware/CsrfMiddleware.php';
require __DIR__.'/../src/Middleware/SecurityHeadersMiddleware.php';

use App\Http\Request;
use App\Http\Response;
use App\Support\Html;
use App\Middleware\CsrfMiddleware;
use App\Middleware\SecurityHeadersMiddleware;

$req = new Request();
$res = new Response();

$pipeline = [
    new SecurityHeadersMiddleware(),
    new CsrfMiddleware(),
];

$next = array_reduce(
    array_reverse($pipeline),
    fn($next, $mw) => fn($req, $res) => $mw($req, $res, $next),
    function(Request $req, Response $res): Response {
        // Router tối giản
        if ($req->method === 'GET' && $req->uri === '/') {
            ob_start();
            include __DIR__.'/form.php';
            $html = ob_get_clean();
            return $res->header('Content-Type', 'text/html; charset=utf-8')->write($html);
        }

        if ($req->method === 'POST' && $req->uri === '/submit') {
            // Xử lý dữ liệu đã qua CSRF middleware
            $username = $req->input('username', '');
            // Escape khi render lại HTML
            return $res->header('Content-Type', 'text/html; charset=utf-8')
                        ->write('<p>Gửi thành công, user: '.Html::e($username).'</p>');
        }

        return $res->status(404)->header('Content-Type','text/plain; charset=utf-8')->write('Not Found');
    }
);

$next($req, $res)->send();

View demo: form có CSRF và anti-XSS

<?php
// public/form.php
use App\Support\Html;
use App\Middleware\SecurityHeadersMiddleware;
$nonceAttr = SecurityHeadersMiddleware::nonceAttr();
?>
<!— Không dùng <head> ở bài blog, nhưng file chạy thật vẫn là HTML hoàn chỉnh —>

<h2>Demo form có CSRF token + auto-escape</h2>
<form method="post" action="/submit">
  <?= Html::csrfInput() ?>
  <label>Username</label>
  <input type="text" name="username" required>
  <button type="submit">Gửi</button>
</form>

<script<?= $nonceAttr ?>>
// Ví dụ thêm CSRF vào Fetch API (AJAX)
(function(){
  const tokenInput = document.querySelector('input[name="_csrf"]');
  window.apiPost = function(url, data) {
    return fetch(url, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'X-CSRF-Token': tokenInput ? tokenInput.value : ''
      },
      credentials: 'same-origin',
      body: JSON.stringify(data)
    });
  };
})();
</script>

Hướng dẫn gắn token vào AJAX và form

  • Với form HTML, chỉ cần in Html::csrfInput() để có input ẩn _csrf.
  • Với Fetch/AJAX, gửi header X-CSRF-Token bằng cách đọc từ input ẩn đã render.
  • Middleware sẽ chặn mọi phương thức POST/PUT/PATCH/DELETE không có token hợp lệ.

Chiến lược giảm XSS

  • Escape output đúng ngữ cảnh: dùng Html::e() khi in dữ liệu người dùng vào HTML.
  • Dùng CSP với nonce: chỉ cho phép script nội bộ có thuộc tính nonce. Middleware đã đặt header và cung cấp nonceAttr().
  • Không chèn HTML thô từ input. Nếu cần rich text, dùng sanitizer chuyên dụng ở server.
  • Tránh eval, new Function, inline event handler vì bị CSP chặn hoặc dễ gây lỗ hổng.

Kiểm thử bảo mật

  1. Mở trang demo, gửi form bình thường: phải thành công.
  2. Mở tab mới, POST tới /submit thiếu _csrf hoặc sai header: phải bị 403.
  3. Thử nhập username chứa ký tự đặc biệt như <script>alert(1)</script>: trang phản hồi phải in ra dạng đã escape, không chạy script.
  4. Kiểm tra response header có CSP, X-Content-Type-Options, X-Frame-Options, Referrer-Policy.

Thực hành tốt nhất

  • SameSite cookie ở session đã bật Strict trong ví dụ, nhưng đừng chỉ dựa vào nó; CSRF token vẫn bắt buộc.
  • Rotate token sau khi xác thực để giảm nguy cơ token reuse.
  • Tách view/template và luôn auto-escape mặc định, chỉ cho phép “raw” ở các vùng đã được sanitize.
  • Audit định kỳ các chỗ in dữ liệu người dùng và endpoint state-changing.

Kết luận

Chỉ với vài file nhỏ gọn, ta đã có middleware chống CSRF và XSS chạy tốt cho PHP thuần: token đồng bộ cho form và AJAX, CSP header kèm nonce, và auto-escape output. Bạn có thể nhúng trực tiếp vào dự án hiện có hoặc đóng gói thành thư viện nội bộ để tái sử dụng.

Bình luận


  • Không có bình luận.

Init Toolbox

Nhấn Ctrl + \ trên máy tính, hoặc vuốt sang trái ở bất kỳ đâu trên mobile.

Đăng nhập





Đang tải...