Tự Viết Plugin Chống DDoS Không Làm Sập Site

Mục lục bài viết14 mục

Bài này chỉ ra cách tự xây plugin WordPress chống DDoS tầng ứng dụng: rate limiter atomic bằng Transients API, fingerprint chống false positive, JS challenge, và đẩy IP tấn công lên Cloudflare khi vượt khả năng xử lý của PHP. Sau khi đọc, bạn viết được plugin hoạt động đúng dưới tải thật, không tự trở thành điểm nghẽn.

Ranh giới thật của một plugin khi chống DDoS

Trước khi viết một dòng code, việc đầu tiên cần làm là vẽ đúng ranh giới: một plugin WordPress chạy hoàn toàn trong PHP, tức là chỉ được thực thi sau khi kết nối TCP đã thiết lập, TLS đã bắt tay xong, và request đã chạm tới tiến trình PHP-FPM. Mọi thứ xảy ra trước điểm đó nằm ngoài tầm với của code bạn viết.

Phân loại theo tầng OSI giúp xác định chính xác việc gì plugin xử lý được:

  • Tấn công tầng mạng/vận chuyển (L3/L4): SYN flood, UDP flood, ICMP flood, hay đơn giản là làm bão hoà băng thông đường truyền. Mục tiêu của kẻ tấn công ở đây là làm đầy hàng đợi kết nối hoặc bão hoà băng thông trước khi gói tin kịp đi đến tầng ứng dụng. Plugin PHP không bao giờ nhìn thấy các gói tin này: chúng chết ở firewall, load balancer, hoặc bị nghẽn đường truyền trước khi tới được server.
  • Tấn công tầng ứng dụng (L7): HTTP flood (spam GET/POST tới một URL cụ thể), lạm dụng xmlrpc.php (đặc biệt kiểu system.multicall cho phép gộp hàng trăm lệnh brute-force vào một request duy nhất), brute-force wp-login.php, hay spam form liên hệ/comment. Đây là vùng plugin có toàn quyền: mỗi request này là một lệnh gọi PHP thật, plugin có thể đọc header, đếm tần suất, và quyết định chặn hay cho qua.

Sai lầm phổ biến nhất khi một dev tự tin viết plugin DDoS lần đầu là coi nhẹ ranh giới này: kỳ vọng plugin “chống DDoS” theo nghĩa chung chung, rồi thất vọng khi site vẫn sập trong một cuộc tấn công SYN flood dù rate limiter báo cáo hoạt động hoàn hảo. Rate limiter đang hoạt động đúng: vấn đề là toàn bộ băng thông hoặc bảng kết nối của server đã bị chiếm dụng ở tầng dưới, trước khi bất kỳ request nào chạm tới WordPress để plugin xử lý.

Điều kiện quan trọng cần nói rõ ở đây, ngay cả trong phạm vi L7 mà plugin xử lý được, có một giới hạn thứ hai ít người để ý: chi phí xử lý một request bị chặn không phải bằng không. Mỗi request, kể cả request bị plugin từ chối, vẫn tốn một worker PHP-FPM, một lượt bootstrap tối thiểu của WordPress (trừ khi bạn chặn ở giai đoạn cực sớm, xem phần kiến trúc tiếp theo), và có thể một lượt đọc cache. Với một cuộc HTTP flood đủ lớn (hàng chục nghìn request/giây từ một botnet phân tán), dù mỗi request bị chặn trong 2 mili-giây, tổng số worker PHP-FPM khả dụng trên server vẫn có thể cạn kiệt trước khi rate limiter kịp “học” đủ để nhận diện toàn bộ dải IP đang tấn công, vì mỗi bot trong botnet thường chỉ gửi vài request trước khi đổi IP.

Đây chính là lý do một plugin DDoS nghiêm túc không dừng ở việc “tự xử lý mọi thứ trong PHP” mà bắt buộc phải có lối thoát sang tầng hạ tầng (CDN, firewall, fail2ban) khi tải vượt quá khả năng xử lý an toàn của chính PHP: nội dung này quay lại chi tiết ở phần tích hợp tầng mạng phía sau bài. Nhiệm vụ thực tế của plugin, nói đúng phạm vi, là: giảm thiểu và làm chậm tấn công L7 quy mô vừa, phát hiện sớm để cảnh báo hoặc leo thang lên hạ tầng khi tấn công vượt ngưỡng, chứ không phải là tuyến phòng thủ duy nhất.

Kiến trúc năm module của plugin

Chia plugin thành năm module với ranh giới trách nhiệm rõ ràng giúp bạn thay đổi hoặc tắt riêng từng phần (ví dụ tắt challenge engine khi debug) mà không đụng vào logic của các phần còn lại. Thứ tự xử lý một request đi qua đúng theo thứ tự này:

Request đến
   │
   ▼
[1] Request Interceptor: điểm hook, quyết định request có đi qua kiểm tra không
   │
   ▼
[2] Fingerprint Engine: trích xuất IP thật, User-Agent, hành vi, tạo một "khoá nhận diện"
   │
   ▼
[3] Rate Limiter: tra cứu/tăng bộ đếm theo khoá đó, so với ngưỡng cấu hình
   │
   ├── dưới ngưỡng ──────────────────────────► cho qua, WordPress xử lý bình thường
   │
   ▼
[4] Challenge Engine: vượt ngưỡng nghi vấn nhưng chưa chắc chắn, phát challenge
   │
   ▼
[5] Response & Logging: quyết định cuối, 429/403 tĩnh hoặc leo thang lên tầng mạng

Request Interceptor chỉ có một việc: chạy càng sớm càng tốt trong vòng đời WordPress (chi tiết ở phần sau) và gọi vào pipeline bốn module còn lại. Nó không chứa logic nghiệp vụ: nhồi logic vào đây là sai lầm kiến trúc phổ biến, vì nó biến điểm hook sớm nhất, nhạy cảm nhất về hiệu năng, thành nơi khó test và khó thay đổi nhất.

Fingerprint Engine chịu trách nhiệm duy nhất là biến một request thô thành một “khoá nhận diện” đáng tin: không chỉ IP đơn thuần (lý do chi tiết ở phần fingerprint phía sau, một IP có thể đại diện cho hàng chục người dùng thật sau NAT). Module này trả về một struct gồm ít nhất: IP đã xác thực qua kiểm tra proxy tin cậy, User-Agent, và một điểm nghi vấn hành vi ban đầu (ví dụ có cookie kiểm tra hay không).

Rate Limiter nhận khoá từ Fingerprint Engine, tra cứu bộ đếm hiện tại (transient hoặc object cache), và trả về một trong ba trạng thái: allow, challenge, hoặc block. Đây là module duy nhất chạm vào tầng lưu trữ persistent trên mỗi request: cố tình giữ các module khác không tự ý đọc/ghi cache riêng để tránh tình trạng nhiều nơi trong code cùng ghi vào cùng một key mà không đồng bộ.

Challenge Engine chỉ được gọi khi Rate Limiter trả về challenge: tức nghi vấn nhưng chưa đủ chắc chắn để chặn cứng. Module này độc lập với Rate Limiter về mặt code (không gọi ngược lại rate limiter) để có thể tắt hoàn toàn cơ chế challenge (ví dụ trên site có nhiều người dùng cao tuổi ít quen thao tác, ưu tiên chặn cứng hơn) mà không phải sửa logic đếm.

Response & Logging là nơi duy nhất quyết định response cuối cùng trả về trình duyệt và nơi duy nhất được phép ghi log hoặc gọi API ra ngoài (Cloudflare, ví dụ ở phần sau). Gom việc này vào một module giúp bạn áp quy tắc hiệu năng duy nhất (không ghi DB đồng bộ trên hot path) tại đúng một chỗ, thay vì phải nhớ áp dụng quy tắc đó rải rác trong bốn module còn lại.

Điều kiện cần nói rõ ở mức chuyên sâu: kiến trúc năm module này giả định plugin chạy đơn tiến trình, đơn site, nếu bạn build cho môi trường multisite hoặc một agency quản lý hàng chục site dùng chung một object cache backend, khoá nhận diện ở Fingerprint Engine bắt buộc phải gồm thêm site ID hoặc domain, nếu không bộ đếm rate-limit của site A và site B sẽ vô tình cộng dồn vào nhau qua cùng một cache backend dùng chung.

Chặn request sớm nhất có thể trong vòng đời WordPress

Một plugin thường (wp-content/plugins/) chỉ chạy được từ hook plugins_loaded trở đi: tại thời điểm đó, WordPress đã kết nối database, đã load toàn bộ core, đã khởi tạo object cache. Nếu bạn đang bị flood hàng nghìn request/giây, mỗi request bị chặn ở plugins_loaded vẫn đã tốn một kết nối DB và một lượt bootstrap gần như đầy đủ trước khi bị từ chối: tức là chi phí chặn gần bằng chi phí phục vụ.

Có hai điểm chặn sớm hơn đáng cân nhắc, theo thứ tự đánh đổi tăng dần giữa độ sớm và độ phức tạp triển khai.

Mu-plugin ở muplugins_loaded

Must-use plugin đặt trong wp-content/mu-plugins/ tự động load, không cần kích hoạt qua giao diện quản trị, và chạy ở hook muplugins_loaded: sớm hơn plugin thường một bước trong chuỗi bootstrap, dù DB đã kết nối tại thời điểm này. Đây là lựa chọn mặc định hợp lý cho phần lớn trường hợp vì đơn giản, không đụng vào file cấu hình của site:

<?php
/**
 * Plugin Name: DDoS Shield (mu-plugin loader)
 */

if ( ! defined( 'ABSPATH' ) ) {
    exit;
}

add_action( 'muplugins_loaded', function () {
    require_once __DIR__ . '/ddos-shield/class-request-interceptor.php';
    ( new DDoS_Shield_Request_Interceptor() )->maybe_block();
}, 1 );

Tham số 1 cho add_action đảm bảo interceptor chạy trước các mu-plugin khác có thể đăng ký cùng hook, nếu site có nhiều mu-plugin.

Drop-in advanced-cache.php

Nếu cần chặn còn sớm hơn, trước cả khi lớp wpdb khởi tạo kết nối database: WordPress cung cấp một cơ chế drop-in được các plugin cache lớn (WP Super Cache, W3 Total Cache) sử dụng, nếu hằng số WP_CACHE được định nghĩa true trong wp-config.php, WordPress include file wp-content/advanced-cache.php ở giai đoạn rất sớm của wp-settings.php, trước khi database connect. Một request bị chặn tại đây gần như không tốn tài nguyên gì ngoài việc load PHP engine và parse header.

<?php
// wp-content/advanced-cache.php
if ( ! defined( 'ABSPATH' ) ) {
    // advanced-cache.php được include trước khi ABSPATH sẵn sàng đầy đủ trong mọi trường hợp,
    // nên tránh phụ thuộc vào các hàm WordPress ở đây: chỉ dùng PHP thuần.
}

$ip = $_SERVER['REMOTE_ADDR'] ?? '';
$blocklist_file = __DIR__ . '/ddos-shield-blocklist.php';

if ( file_exists( $blocklist_file ) ) {
    $blocked_ips = include $blocklist_file; // mảng IP, sinh ra định kỳ bởi phần logic chạy ở muplugins_loaded
    if ( in_array( $ip, $blocked_ips, true ) ) {
        http_response_code( 429 );
        header( 'Retry-After: 60' );
        exit( 'Too Many Requests' );
    }
}

Lưu ý cách chia việc: file advanced-cache.php chỉ đọc một danh sách IP đã bị chặn (ghi sẵn ra file PHP thuần bởi module Response & Logging chạy ở muplugins_loaded), chứ không tự tính toán rate limit ở đây, vì tại thời điểm này chưa có DB, chưa có object cache, không có chỗ nào an toàn để đọc/ghi bộ đếm theo thời gian thực. Đây là kỹ thuật hai lớp: lớp muplugins_loaded tính toán và “kết án” IP, lớp advanced-cache.php chỉ tra cứu danh sách đã kết án đó với chi phí gần như bằng không.

Điều kiện quan trọng cần nói thẳng: advanced-cache.php là một file drop-in duy nhất, WordPress không hỗ trợ nhiều plugin cùng đăng ký vào đó. Nếu site đã cài WP Super Cache, W3 Total Cache, hoặc WP Rocket, slot này đã bị chiếm. Plugin không nên tự động ghi đè file của plugin cache khác khi kích hoạt: đó là hành vi phá vỡ site một cách âm thầm. Cách xử lý đúng: khi activate, kiểm tra file_exists( WP_CONTENT_DIR . '/advanced-cache.php' ); nếu file đã tồn tại và không phải do chính plugin bạn tạo ra (đánh dấu bằng một comment nhận diện ở đầu file), lùi về chặn ở muplugins_loaded và hiển thị thông báo cho admin biết rằng lớp chặn sớm nhất không khả dụng do xung đột, thay vì âm thầm ghi đè.

Rate limiting engine không dính race condition

Cách viết rate limiter theo bản năng, đọc transient rồi cộng một rồi ghi lại, sai ở đúng lúc bạn cần nó đúng nhất:

// SAI: dính race condition dưới tải cao
function ddos_shield_naive_increment( string $key ): int {
    $count = (int) get_transient( $key );
    $count++;
    set_transient( $key, $count, 10 ); // cửa sổ 10 giây
    return $count;
}

Với hai request đến gần như đồng thời (khoảng cách vài mili-giây, chuyện bình thường khi bị flood), cả hai đều đọc được cùng giá trị cũ trước khi request nào kịp ghi lại: kết quả là bộ đếm mất một lượt (lost update). Dưới flood thật với hàng trăm request/giây cùng một khoá, bộ đếm quan sát được luôn thấp hơn đáng kể so với số request thực tế, và ngưỡng chặn không bao giờ được kích hoạt đúng lúc.

Transients API mặc định lưu trong bảng wp_options và không có phép cộng nguyên tử. Cách khắc phục đúng là dùng một câu lệnh SQL nguyên tử duy nhất, thay vì hai bước đọc-rồi-ghi, trên một bảng riêng do plugin tự tạo:

class DDoS_Shield_Rate_Limiter {

    private $table;

    public function __construct() {
        global $wpdb;
        $this->table = $wpdb->prefix . 'ddos_shield_counters';
    }

    public function increment_and_get( string $key, int $window_seconds ): int {
        global $wpdb;
        $now    = time();
        $bucket = (int) floor( $now / $window_seconds ); // fixed-window bucket

        // INSERT ... ON DUPLICATE KEY UPDATE là một thao tác nguyên tử ở tầng InnoDB,
        // loại bỏ hoàn toàn khoảng hở giữa đọc và ghi.
        $wpdb->query(
            $wpdb->prepare(
                "INSERT INTO {$this->table} (rate_key, bucket, hits)
                 VALUES (%s, %d, 1)
                 ON DUPLICATE KEY UPDATE hits = hits + 1",
                $key,
                $bucket
            )
        );

        $hits = $wpdb->get_var(
            $wpdb->prepare(
                "SELECT hits FROM {$this->table} WHERE rate_key = %s AND bucket = %d",
                $key,
                $bucket
            )
        );

        return (int) $hits;
    }
}

Bảng ddos_shield_counters cần khoá chính composite (rate_key, bucket) để ON DUPLICATE KEY UPDATE hoạt động đúng, và nên có một cron dọn định kỳ các bucket đã hết hạn (bucket cũ hơn vài phút) để bảng không phình vô hạn. Nếu site đã có persistent object cache (Redis/Memcached qua một plugin object-cache backend), wp_cache_incr() là lựa chọn nhanh hơn nhiều so với query MySQL vì backend đó vốn hỗ trợ tăng nguyên tử ở tầng in-memory, nên ưu tiên nhánh này khi phát hiện được, và chỉ rơi về bảng MySQL riêng khi không có persistent cache.

Fixed-window ngắn (bucket 10 giây thay vì 60 giây) là xấp xỉ thực dụng cho sliding window thật: sliding window chính xác đòi hỏi lưu danh sách timestamp từng request, tốn bộ nhớ hơn nhiều lần cho lợi ích cận biên không đáng ở phạm vi một plugin WordPress. Cần nói rõ giới hạn của xấp xỉ này: fixed window có lỗ hổng burst ở biên hai bucket liền kề, vì kẻ tấn công có thể gửi đủ số request cho phép vào giây cuối của bucket này và lại đủ số đó vào giây đầu bucket kế tiếp, tạo ra gấp đôi lưu lượng cho phép trong một khoảng thời gian ngắn thực tế. Dùng bucket ngắn (10 giây) thay vì dài (60 giây) không loại bỏ lỗ hổng này nhưng thu hẹp cửa sổ có thể lợi dụng xuống mức không đáng kể trong đa số kịch bản.

Ngưỡng không nên đồng nhất cho mọi endpoint. wp-login.php và xmlrpc.php là hai mục tiêu brute-force kinh điển và nên có ngưỡng riêng, thấp hơn nhiều so với trang nội dung thông thường:

function ddos_shield_get_threshold( string $request_uri ): array {
    if ( str_contains( $request_uri, 'wp-login.php' ) || str_contains( $request_uri, 'xmlrpc.php' ) ) {
        return [ 'limit' => 5, 'window' => 10 ]; // 5 request / 10 giây
    }
    return [ 'limit' => 60, 'window' => 10 ]; // 60 request / 10 giây cho trang thường
}

Riêng xmlrpc.php xứng đáng một cân nhắc thêm: phương thức system.multicall cho phép gộp nhiều lệnh xác thực vào một request HTTP duy nhất, nghĩa là rate limiter đếm theo số request sẽ hoàn toàn bỏ lọt một cuộc brute-force gộp lệnh kiểu này. Với endpoint này, nếu không có nhu cầu nghiệp vụ thực sự dùng XML-RPC (đa số site không dùng), phương án chắc chắn hơn là chặn hẳn system.multicall ở tầng ứng dụng bằng filter xmlrpc_methods, thay vì chỉ trông cậy vào đếm tần suất.

Fingerprint và giảm false positive cho IP dùng chung

Dùng IP làm khoá chặn duy nhất là cách nhanh nhất để một plugin DDoS tự bắn vào chân site đang bảo vệ. Mạng công ty, trường học, hay một số nhà mạng di động dồn hàng chục đến hàng trăm người dùng thật ra chung một IP công cộng qua NAT, nếu ngưỡng chặn tính theo IP đơn thuần, một nhóm nhân viên cùng công ty duyệt web bình thường có thể vô tình vượt ngưỡng và cả toà nhà bị khoá khỏi site.

Khoá nhận diện đúng phải là tổ hợp, không phải IP đơn lẻ:

function ddos_shield_build_fingerprint_key( string $ip, string $user_agent ): string {
    // Băm UA để tránh key quá dài, đồng thời chuẩn hoá UA rỗng thành một giá trị cố định
    $ua_hash = $user_agent !== '' ? substr( md5( $user_agent ), 0, 12 ) : 'no-ua';
    return "ddos_shield:{$ip}:{$ua_hash}";
}

Tổ hợp IP + hash User-Agent đã tách được phần lớn trường hợp NAT hợp lệ: nhân viên trong cùng công ty thường dùng trình duyệt/thiết bị khác nhau nên UA khác nhau, mỗi tổ hợp IP+UA có bộ đếm riêng thay vì cộng dồn vào một khoá chung. Nhưng UA là header do client tự khai, giả mạo trong một dòng code, nên không dùng nó như bằng chứng độc lập, chỉ dùng để phân tách các luồng traffic hợp lệ khỏi nhau, chứ không dùng để chứng minh một luồng là hợp lệ.

Tín hiệu đáng tin hơn nằm ở hành vi, không phải ở header tự khai. Hai tín hiệu hành vi rẻ để kiểm tra và khó giả mạo hàng loạt:

  • Có tải resource phụ hay không. Một trình duyệt thật load một trang HTML sẽ kéo theo hàng loạt request phụ (CSS, JS, ảnh) trong vài trăm mili-giây tiếp theo. Một bot HTTP-flood đơn giản (script gọi thẳng một URL bằng curl/requests) chỉ tạo đúng một request, không có request phụ nào theo sau. Theo dõi việc này cần một cửa sổ thời gian ngắn (1-2 giây) sau request đầu, không chặn ngay tại request đó.
  • Có giữ cookie giữa các lần request không. Đặt một cookie kiểm tra ngắn hạn ở lần request đầu tiên từ một fingerprint mới; bot không chạy JS và không lưu cookie qua các lần gọi riêng biệt sẽ liên tục “trông như” lần đầu tiên ở mọi request, trong khi trình duyệt thật sẽ gửi lại cookie đó từ lần thứ hai trở đi.
function ddos_shield_score_suspicion( array $fingerprint_data ): int {
    $score = 0;

    if ( empty( $fingerprint_data['has_check_cookie'] ) ) {
        $score += 2;
    }
    if ( empty( $fingerprint_data['user_agent'] ) ) {
        $score += 3; // UA rỗng gần như luôn là script, không phải trình duyệt thật
    }
    if ( $fingerprint_data['requests_in_window'] > 20 && ! $fingerprint_data['loaded_subresources'] ) {
        $score += 3; // nhiều request nhưng chưa từng kéo theo resource phụ
    }

    return $score; // 0-8: dùng làm đầu vào quyết định allow/challenge/block ở Rate Limiter
}

Với các bot hợp lệ cần được cho qua vô điều kiện, quan trọng nhất là Googlebot và các search engine crawler khác, whitelist theo User-Agent đơn thuần (str_contains( $ua, 'Googlebot' )) là lỗ hổng giả mạo rõ ràng: bất kỳ ai cũng có thể tự khai UA là Googlebot để né mọi kiểm tra. Xác thực đúng cần hai chiều: tra ngược DNS (reverse DNS lookup) từ IP của request, kiểm tra tên miền trả về có thuộc googlebot.com hoặc google.com không, rồi tra xuôi (forward DNS) lại chính tên miền đó để xác nhận nó thật sự resolve về đúng IP ban đầu, vì bước tra xuôi này ngăn kẻ tấn công tự dựng một bản ghi PTR giả trỏ về domain trông giống Google trên hạ tầng họ kiểm soát.

function ddos_shield_is_verified_googlebot( string $ip ): bool {
    $hostname = gethostbyaddr( $ip );
    if ( ! $hostname || ! preg_match( '/\.(googlebot\.com|google\.com)$/', $hostname ) ) {
        return false;
    }
    $resolved_ips = gethostbynamel( $hostname );
    return is_array( $resolved_ips ) && in_array( $ip, $resolved_ips, true );
}

Việc tra DNS hai chiều tốn vài chục đến vài trăm mili-giây mỗi lần gọi: không nên chạy trên mọi request khai UA là bot, mà chỉ chạy khi UA khớp pattern bot đã biết, và cache kết quả (IP đã xác thực là Googlebot thật) bằng transient dài hạn (24 giờ) để không phải tra lại DNS mỗi lần Googlebot ghé thăm.

Xác thực IP thật, chống giả mạo X-Forwarded-For

Mọi module ở phần trước, từ rate limiter, fingerprint, đến whitelist Googlebot, đều dựa trên một giả định ngầm: giá trị IP đưa vào là IP thật của client. Giả định đó sai ngay khi site đứng sau bất kỳ proxy hay CDN nào, và sai theo hướng nguy hiểm nhất có thể: X-Forwarded-For và các header tương đương (CF-Connecting-IP của Cloudflare, X-Real-IP) là header do client hoặc proxy trung gian tự đặt, không phải giá trị do server tự đo được như REMOTE_ADDR.

Nếu code tin thẳng header này:

// SAI: tin thẳng header do client kiểm soát
function ddos_shield_get_client_ip_naive(): string {
    return $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['REMOTE_ADDR'];
}

kẻ tấn công gửi thẳng request tới server (không qua CDN nào) với header X-Forwarded-For: 1.2.3.4 tuỳ ý, plugin sẽ ghi nhận toàn bộ hoạt động tấn công là đến từ 1.2.3.4, một IP bất kỳ mà kẻ tấn công chọn, kể cả IP của một người dùng thật khác. Hai hậu quả cùng lúc: rate limiter không bao giờ khoá đúng IP thật của kẻ tấn công (vì REMOTE_ADDR thật không được dùng), và một người dùng vô can có thể bị khoá oan nếu kẻ tấn công cố tình đặt IP của họ vào header.

Quy tắc bắt buộc: chỉ tin X-Forwarded-For/CF-Connecting-IP khi REMOTE_ADDR (giá trị lấy từ kết nối TCP thật, không thể giả mạo ở tầng ứng dụng) khớp với dải IP của proxy/CDN mà bạn thật sự đứng sau.

class DDoS_Shield_IP_Resolver {

    public function get_trusted_client_ip(): string {
        $remote_addr = $_SERVER['REMOTE_ADDR'] ?? '';

        if ( ! $this->is_trusted_proxy( $remote_addr ) ) {
            return $remote_addr; // không qua proxy tin cậy: REMOTE_ADDR chính là IP thật
        }

        $forwarded = $_SERVER['HTTP_CF_CONNECTING_IP'] ?? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? '';
        $forwarded = trim( explode( ',', $forwarded )[0] ); // lấy IP đầu tiên nếu có chuỗi nhiều IP

        return filter_var( $forwarded, FILTER_VALIDATE_IP ) ? $forwarded : $remote_addr;
    }

    private function is_trusted_proxy( string $ip ): bool {
        $trusted_ranges = get_transient( 'ddos_shield_cf_ip_ranges' );
        if ( false === $trusted_ranges ) {
            $trusted_ranges = $this->refresh_trusted_ranges();
        }
        foreach ( $trusted_ranges as $cidr ) {
            if ( $this->ip_in_cidr( $ip, $cidr ) ) {
                return true;
            }
        }
        return false;
    }

    private function refresh_trusted_ranges(): array {
        $ranges = [];
        foreach ( [ 'https://www.cloudflare.com/ips-v4', 'https://www.cloudflare.com/ips-v6' ] as $url ) {
            $response = wp_remote_get( $url, [ 'timeout' => 5 ] );
            if ( ! is_wp_error( $response ) ) {
                $ranges = array_merge( $ranges, array_filter( explode( "\n", trim( wp_remote_retrieve_body( $response ) ) ) ) );
            }
        }
        set_transient( 'ddos_shield_cf_ip_ranges', $ranges, DAY_IN_SECONDS );
        return $ranges;
    }

    private function ip_in_cidr( string $ip, string $cidr ): bool {
        // Triển khai so khớp CIDR thật (IPv4/IPv6) cần thư viện hoặc hàm riêng đầy đủ: 
        // đây chỉ minh hoạ luồng gọi, không phải bản triển khai bit-mask hoàn chỉnh.
        [ $subnet, $bits ] = array_pad( explode( '/', $cidr ), 2, 32 );
        return ( ip2long( $ip ) & ~( ( 1 << ( 32 - (int) $bits ) ) - 1 ) ) === ( ip2long( $subnet ) & ~( ( 1 << ( 32 - (int) $bits ) ) - 1 ) );
    }
}

Danh sách dải IP tin cậy không nên hard-code cố định trong plugin: Cloudflare cập nhật dải IP công khai của họ theo thời gian, và một danh sách cũ khiến request hợp lệ từ dải IP mới bị coi là “không qua proxy tin cậy”, làm sai lệch toàn bộ IP ghi nhận cho các request đó. Đoạn code trên tự fetch danh sách hiện hành từ endpoint công khai cloudflare.com/ips-v4 và ips-v6, cache lại 24 giờ bằng transient, và nên gắn thêm vào một sự kiện WP-Cron chạy hàng ngày để làm mới chủ động thay vì chỉ chờ transient hết hạn giữa lúc đang có traffic.

Nếu bạn không đứng sau Cloudflare mà dùng CDN khác hoặc load balancer nội bộ, thay endpoint fetch danh sách bằng nguồn tương ứng của nhà cung cấp đó, hoặc với load balancer tự quản lý, dùng trực tiếp dải IP nội bộ cố định của chính hạ tầng bạn kiểm soát: nguyên tắc “chỉ tin header khi REMOTE_ADDR khớp dải tin cậy đã xác minh” áp dụng như nhau bất kể proxy cụ thể là gì.

Cơ chế JS challenge thay vì CAPTCHA cứng

Khi Rate Limiter trả về trạng thái challenge (nghi vấn nhưng chưa đủ chắc chắn để chặn cứng), đẩy thẳng người dùng vào CAPTCHA hình ảnh là phản ứng thái quá: CAPTCHA đòi hỏi tương tác thủ công, gây khó chịu cho người dùng thật và đặc biệt cản trở người dùng trình đọc màn hình. Một cơ chế nhẹ hơn nhiều nhưng vẫn lọc được phần lớn bot HTTP-flood đơn giản là JS challenge thụ động: yêu cầu trình duyệt tự chạy một đoạn JavaScript để set cookie, không cần người dùng bấm hay nhìn thấy gì.

Luồng xử lý:

Request nghi vấn tới
   │
   ▼
Không có cookie challenge hợp lệ?
   │
   ├── Có ──────────────────► cho qua, WordPress xử lý bình thường
   │
   └── Không ──► trả HTTP 200 kèm trang trung gian có script set cookie
                    │
                    ▼
              Trình duyệt chạy JS, set cookie, tự reload
                    │
                    ▼
              Request lần 2 mang cookie hợp lệ ──► cho qua

Trang trung gian chứa một nonce do server ký, để cookie không thể bị đoán hoặc tái sử dụng giữa các fingerprint khác nhau:

class DDoS_Shield_Challenge_Engine {

    private const COOKIE_NAME = 'ddos_shield_ck';

    public function issue_challenge(): void {
        $nonce = wp_generate_password( 24, false );
        $token = hash_hmac( 'sha256', $nonce . $this->get_fingerprint_key(), $this->get_secret() );

        // Lưu token hợp lệ tạm thời để bước xác minh sau đối chiếu lại: tránh việc
        // client tự chế cookie mà không thật sự đi qua bước chạy JS.
        set_transient( "ddos_shield_pending_{$nonce}", $token, 30 );

        status_header( 200 );
        header( 'Cache-Control: no-store' );
        ?>
        <!DOCTYPE html>
        <html><head><meta charset="utf-8"><title>Đang xác minh...</title></head>
        <body>
        <script>
        document.cookie = "<?php echo esc_js( self::COOKIE_NAME ); ?>=<?php echo esc_js( $nonce . ':' . $token ); ?>;path=/;max-age=300;SameSite=Lax";
        location.reload();
        </script>
        <noscript>Trình duyệt của bạn cần bật JavaScript để tiếp tục truy cập trang này.</noscript>
        </body></html>
        <?php
        exit;
    }

    public function verify_challenge_cookie(): bool {
        $cookie = $_COOKIE[ self::COOKIE_NAME ] ?? '';
        if ( ! str_contains( $cookie, ':' ) ) {
            return false;
        }
        [ $nonce, $token ] = explode( ':', $cookie, 2 );
        $expected = get_transient( "ddos_shield_pending_{$nonce}" );
        return $expected && hash_equals( $expected, $token );
    }
}

Vì trang trung gian trả HTTP 200 (không phải 403/429) và không yêu cầu người dùng làm gì, trình duyệt thật, kể cả chạy cùng trình đọc màn hình vì không có tương tác nào bị yêu cầu ngoài việc engine JS chạy, vượt qua trong dưới một giây mà người dùng gần như không nhận ra có gì xảy ra. Bot HTTP đơn giản viết bằng curl, thư viện requests của Python, hay các script flood tự chế không có engine JS nên không bao giờ set được cookie; mọi request tiếp theo của nó tiếp tục rơi vào nhánh challenge và bị đếm vào rate limiter như một request nghi vấn liên tục, đẩy nhanh việc nó bị chuyển sang trạng thái block.

Giới hạn thật cần nói thẳng ở đây, vì đây là chỗ lời khuyên “cứ thêm JS challenge là chặn được bot” không còn đúng: headless browser thật (Puppeteer, Playwright, Selenium chạy Chrome/Firefox thật) chạy JS đầy đủ và vượt qua JS challenge dễ dàng như một trình duyệt người dùng thật, chỉ tốn kẻ tấn công nhiều RAM/CPU hơn để vận hành mỗi instance so với một script HTTP thuần. JS challenge không phải giải pháp cho một botnet dùng trình duyệt thật quy mô lớn: nó chỉ nâng chi phí vận hành của tầng bot rẻ tiền nhất lên đủ cao để loại phần lớn kịch bản tấn công nghiệp dư, trong khi để lại phần botnet tinh vi hơn cho rate limiter và, nếu cần, tầng CDN xử lý (CDN có khả năng phân tích TLS fingerprint mà một plugin PHP thuần không thể tự làm, vì thông tin đó không lộ ra ở tầng ứng dụng).

Một điểm vận hành dễ bỏ sót: cookie challenge có max-age hữu hạn (300 giây trong ví dụ trên), người dùng thật ở lại site lâu hơn mức đó sẽ bị yêu cầu challenge lại. Đặt thời gian sống quá ngắn tạo trải nghiệm khó chịu không cần thiết; đặt quá dài làm giảm tác dụng của cơ chế với một bot đã từng vượt qua một lần rồi giữ nguyên cookie để tiếp tục tấn công lâu dài. 300-600 giây là điểm cân bằng hợp lý cho phần lớn site, nên để đây là một hằng số cấu hình được thay vì hard-code.

Giữ plugin không tự trở thành điểm nghẽn

Sai lầm hiệu năng phổ biến nhất ở phần logging không nằm ở rate limiter, mà nằm ở chỗ tưởng như vô hại: ghi lại mỗi request bị chặn để admin xem sau.

// SAI: biến một cuộc tấn công ứng dụng thành một cuộc tấn công ghi đè database
add_action( 'ddos_shield_request_blocked', function ( $ip, $reason ) {
    global $wpdb;
    $wpdb->insert( $wpdb->prefix . 'ddos_shield_log', [
        'ip'        => $ip,
        'reason'    => $reason,
        'timestamp' => current_time( 'mysql' ),
    ] );
} );

Dưới traffic bình thường, đoạn code này vô hại. Dưới một cuộc HTTP flood thật với vài nghìn request bị chặn mỗi giây, nó biến chính hành động phòng thủ thành nguồn tải ghi ghi DB lớn nhất mà site đang phải chịu: mỗi INSERT có index cần cập nhật, disk I/O thật, và ở mức đủ cao có thể khiến MySQL trở thành nút thắt trước cả khi kẻ tấn công đạt được mục đích ban đầu của họ.

Nguyên tắc cần giữ nghiêm: đường xử lý chặn (hot path) không được ghi DB đồng bộ cho mỗi request. Bộ đếm rate-limit đã cần một cơ chế nhanh (bảng đếm nguyên tử hoặc object cache, như ở phần rate limiting), nên tận dụng lại đúng cơ chế đó cho việc quyết định có log hay không, thay vì log riêng mỗi lần chặn:

class DDoS_Shield_Logger {

    public function maybe_log( string $ip, string $reason ): void {
        $seen_key = "ddos_shield_logged:{$ip}";

        // Chỉ ghi log khi đây là lần ĐẦU TIÊN ip này bị chặn trong cửa sổ hiện tại: 
        // các lần chặn tiếp theo của cùng IP trong cùng cửa sổ không tạo thêm dòng log.
        if ( get_transient( $seen_key ) ) {
            $this->bump_repeat_counter( $ip );
            return;
        }
        set_transient( $seen_key, 1, 5 * MINUTE_IN_SECONDS );

        $this->write_line( sprintf(
            "[%s] BLOCK ip=%s reason=%s repeat_count=%d\n",
            gmdate( 'c' ),
            $ip,
            $reason,
            $this->get_repeat_counter( $ip )
        ) );
    }

    private function write_line( string $line ): void {
        static $handle = null;
        if ( null === $handle ) {
            $handle = fopen( WP_CONTENT_DIR . '/ddos-shield.log', 'a' );
        }
        fwrite( $handle, $line );
    }

    private function bump_repeat_counter( string $ip ): void {
        wp_cache_incr( "ddos_shield_repeat:{$ip}", 1, 'ddos_shield' );
    }

    private function get_repeat_counter( string $ip ): int {
        return (int) wp_cache_get( "ddos_shield_repeat:{$ip}", 'ddos_shield' );
    }
}

Hai thay đổi cùng lúc so với bản sai ở trên đáng chú ý: log ghi ra file phẳng theo kiểu append (fopen/fwrite) thay vì bảng DB có index, vì append vào cuối file rẻ hơn nhiều lần so với một INSERT có index cần cập nhật; và chỉ dòng log đầu tiên của mỗi IP trong một cửa sổ 5 phút thật sự được ghi, các lần chặn lặp lại của cùng IP chỉ tăng một bộ đếm trong bộ nhớ cache thay vì tạo thêm dòng log. Khi dòng log đầu tiên được ghi, nó vẫn mang đủ thông tin hữu ích cho admin nhờ repeat_count: con số này phản ánh IP đó đã bị chặn bao nhiêu lần tính đến thời điểm ghi, dù không có từng đó dòng log riêng lẻ trong file.

File log cần giới hạn kích thước và xoay vòng (log rotation): một cuộc tấn công kéo dài nhiều giờ vẫn có thể làm file phình lớn dù đã giảm tần suất ghi, và một file log không giới hạn là cách âm thầm làm đầy ổ đĩa server, tự nó có thể gây sập site độc lập với cuộc tấn công ban đầu. Cách đơn giản nhất không cần thư viện ngoài: kiểm tra kích thước file mỗi lần ghi (hoặc định kỳ qua WP-Cron), đổi tên file hiện tại thành file .1 khi vượt một ngưỡng (ví dụ 50 MB), và xoá các bản .N cũ hơn một số lượng cố định.

Định dạng dòng log ở trên ([timestamp] BLOCK ip=... reason=... repeat_count=...) không phải ngẫu nhiên: viết theo kiểu dễ grep và dễ viết regex là chủ đích, vì đây chính là file mà fail2ban sẽ đọc để tự động chặn ở tầng hệ điều hành, nội dung này quay lại chi tiết ở phần tích hợp tầng mạng tiếp theo.

Đẩy IP tấn công lên tầng mạng qua Cloudflare API

Mọi cơ chế ở các phần trước đều chạy trong PHP, nghĩa là request vẫn phải chạm tới server trước khi bị từ chối. Với một IP đã được xác nhận là tấn công dai dẳng (không phải một lần vượt ngưỡng ngẫu nhiên), bước tiếp theo hợp lý là đẩy việc chặn lên tầng edge của CDN: nơi request bị từ chối trước khi bao giờ chạm tới origin server, tiết kiệm toàn bộ chi phí xử lý mà plugin PHP vẫn phải trả dù chặn nhanh đến đâu.

Điều kiện kích hoạt việc gọi API không nên là “IP vừa vượt ngưỡng rate-limit lần đầu”: quá nhạy, sẽ gọi API liên tục cho những vi phạm nhỏ và thoáng qua. Dùng một ngưỡng thứ hai, cao hơn: IP tiếp tục bị chặn cục bộ nhiều lần trong một khoảng thời gian đủ dài, tận dụng đúng bộ đếm repeat_count đã xây ở phần logging.

class DDoS_Shield_Cloudflare_Escalator {

    private const ESCALATION_THRESHOLD = 50; // số lần bị chặn cục bộ trước khi leo thang
    private const API_CALL_WINDOW       = 300; // giới hạn tự gọi API tối đa 1 lần / 5 phút / IP

    public function maybe_escalate( string $ip, int $repeat_count ): void {
        if ( $repeat_count < self::ESCALATION_THRESHOLD ) {
            return;
        }

        $already_called_key = "ddos_shield_cf_called:{$ip}";
        if ( get_transient( $already_called_key ) ) {
            return; // tránh gọi API lặp lại cho cùng IP trong cửa sổ ngắn
        }
        set_transient( $already_called_key, 1, self::API_CALL_WINDOW );

        $this->create_cloudflare_block_rule( $ip );
    }

    private function create_cloudflare_block_rule( string $ip ): void {
        $zone_id = get_option( 'ddos_shield_cf_zone_id' );
        $api_token = get_option( 'ddos_shield_cf_api_token' ); // lưu qua Settings API, không hard-code

        $response = wp_remote_post(
            "https://api.cloudflare.com/client/v4/zones/{$zone_id}/firewall/access_rules/rules",
            [
                'timeout' => 5,
                'headers' => [
                    'Authorization' => "Bearer {$api_token}",
                    'Content-Type'  => 'application/json',
                ],
                'body' => wp_json_encode( [
                    'mode'          => 'block',
                    'configuration' => [ 'target' => 'ip', 'value' => $ip ],
                    'notes'         => 'Auto-blocked by DDoS Shield: ' . gmdate( 'c' ),
                ] ),
            ]
        );

        // Lỗi gọi API: kể cả do chính Cloudflare rate-limit request tới API của họ: 
        // không được phép làm gián đoạn luồng chính. Ghi log một dòng và dừng lại,
        // không retry dồn dập.
        if ( is_wp_error( $response ) || wp_remote_retrieve_response_code( $response ) >= 400 ) {
            error_log( "DDoS Shield: Cloudflare API escalation failed for {$ip}" );
        }
    }
}

Nguyên tắc thiết kế quan trọng nhất ở module này không nằm trong code trên mà nằm ở cách nó được gọi: việc chặn cục bộ trong PHP luôn phải là lớp phòng thủ chính, hoạt động độc lập hoàn toàn với việc gọi Cloudflare API có thành công hay không. Lệnh gọi API chỉ là một hành động “nâng cấp” chạy song song, có tự giới hạn tần suất gọi ra ngoài (biến API_CALL_WINDOW ở trên), và nếu thất bại thì im lặng bỏ qua chứ không retry, vì bản thân Cloudflare API cũng có giới hạn tốc độ riêng cho mỗi token, và trong lúc đang bị tấn công thật là đúng lúc plugin dễ vô tình dội bom chính API đó nhất nếu không tự kiềm chế. Con số giới hạn chính xác hiện hành của Cloudflare API nên được kiểm tra trực tiếp trong tài liệu API của Cloudflare tại thời điểm bạn triển khai, vì các nhà cung cấp có xu hướng điều chỉnh con số này theo thời gian.

Với các đội có quyền truy cập trực tiếp vào server (không chỉ CDN), một hướng bổ sung không cần gọi API ra ngoài là để fail2ban đọc chính file log đã xây ở phần trước và tự chặn ở tầng iptables/nftables. Vì dòng log đã viết theo định dạng cố định và dễ parse (BLOCK ip=... reason=...), việc còn lại chỉ là viết một filter regex tương ứng cho fail2ban:

# /etc/fail2ban/filter.d/ddos-shield.conf
[Definition]
failregex = ^\[.*\] BLOCK ip=<HOST> reason=.*$
ignoreregex =
# /etc/fail2ban/jail.d/ddos-shield.conf

[ddos-shield]

enabled = true filter = ddos-shield logpath = /path/to/wp-content/ddos-shield.log maxretry = 50 findtime = 300 bantime = 3600 action = iptables-multiport[name=ddos-shield, port=”http,https”]

Chặn ở iptables xử lý gói tin ở tầng kernel, rẻ hơn nhiều bậc so với việc mỗi request vẫn phải chạm tới PHP-FPM rồi mới bị plugin từ chối: với các IP đã chắc chắn là tấn công dai dẳng, đây là lớp phòng thủ hiệu quả nhất trong toàn bộ kiến trúc, dù nó nằm hoàn toàn ngoài phạm vi code PHP của chính plugin.

Kiểm thử tải để xác nhận rate limiter hoạt động

Code chạy không lỗi không chứng minh rate limiter hoạt động đúng dưới tải thật: race condition, ngưỡng sai, hay lỗ hổng burst ở biên fixed-window (phần rate limiting đã nói ở trên) đều là những lỗi chỉ lộ ra khi có nhiều request đồng thời thật sự, không phải khi gọi thử vài lần bằng tay trên trình duyệt.

wrk là công cụ phù hợp hơn Apache Bench (ab) cho việc này: ab là công cụ đơn luồng, dễ tự trở thành nút thắt phía client trước khi tạo đủ tải để thật sự kiểm tra được rate limiter; wrk dùng multi-thread kết hợp event loop, tạo tải cao với overhead thấp hơn nhiều trên cùng một máy client.

wrk -t4 -c200 -d30s http://staging-site.test/wp-login.php

Lệnh trên dùng 4 thread, 200 kết nối đồng thời, chạy trong 30 giây, nhắm vào wp-login.php: đúng endpoint có ngưỡng thấp nhất theo cấu hình ở phần rate limiting. Hai điều kiện bắt buộc khi chạy: chạy trên site staging, không phải production, vì đây là traffic giả lập tấn công thật; và lý tưởng nhất là chạy từ một máy khác, không phải chính server đang host site, để tránh tải của chính wrk tranh chấp CPU/băng thông với site đang bị test, làm sai lệch kết quả đo được.

Xác nhận rate limiter hoạt động đúng cần quan sát hai chỉ số cùng lúc, không phải một:

  • Tỷ lệ response 429/403 tăng rõ rệt sau khi vượt ngưỡng cấu hình: wrk in ra bảng phân bố mã trạng thái ở cuối, nếu phần lớn response vẫn là 200 dù đã vượt xa ngưỡng đặt ra, rate limiter không kích hoạt đúng (nghi ngờ đầu tiên nên là race condition ở bộ đếm, xem lại phần rate limiting).
  • Một phiên “sạch” chạy song song vẫn nhận 200 xuyên suốt. Mở một terminal thứ hai, chạy một script gọi wp-login.php với tần suất thấp mô phỏng người dùng thật (một request mỗi 5 giây) trong lúc wrk đang chạy tải cao. Nếu phiên sạch này cũng bắt đầu nhận 429/403, rate limiter đang chặn quá rộng: ví dụ đang khoá cả trang thay vì khoá đúng theo fingerprint của luồng tấn công, một lỗi nghiêm trọng hơn nhiều so với việc chặn thiếu, vì nó tự tay khoá người dùng thật ngay giữa lúc site cần họ truy cập được nhất.
# terminal 2, chạy song song với wrk ở trên
while true; do
  curl -s -o /dev/null -w "%{http_code}\n" http://staging-site.test/wp-login.php
  sleep 5
done

Điều kiện cần nói rõ ở mức chuyên sâu: kết quả test trên staging chỉ xác nhận logic của rate limiter đúng, nó không xác nhận hiệu năng thực tế dưới tải production. Staging thường không có traffic nền cạnh tranh tài nguyên, không có cache đã warm sẵn, và nếu production đứng sau CDN còn staging thì không, độ trễ đo được trên staging không phản ánh đúng độ trễ thật do plugin cộng thêm vào mỗi request (mỗi request giờ tốn thêm ít nhất một lượt đọc bộ đếm). Cách bù đắp thực tế: sau khi xác nhận logic đúng trên staging, chạy thêm một vòng test tải nhẹ hơn nhiều (không mô phỏng tấn công, chỉ đo overhead) trực tiếp trên production ở giờ thấp điểm, hoặc dùng cơ chế canary, bật plugin cho một tỷ lệ nhỏ traffic thật trước khi bật toàn bộ, để đo độ trễ thêm vào có nằm trong ngưỡng chấp nhận được của site hay không trước khi triển khai toàn diện.

Sai lầm thường gặp khi tự viết plugin bảo mật WordPress

Bốn lỗi dưới đây lặp lại nhiều nhất khi một plugin DDoS tự viết được đưa vào production lần đầu, đều là loại lỗi chỉ lộ ra sau khi đã gây thiệt hại thật, không phải lúc code review.

Tự khoá nhầm chính admin. Rate limiter tính theo IP+fingerprint không phân biệt vai trò người dùng: một admin đang thao tác nhanh (import dữ liệu hàng loạt qua AJAX, chạy một script kiểm tra tự động, hoặc đơn giản là thao tác nhiều tab cùng lúc) hoàn toàn có thể tự vượt ngưỡng thiết kế cho traffic công khai. Xử lý đúng không phải là whitelist toàn bộ IP admin (một kẻ tấn công chiếm được session admin sẽ lợi dụng đúng whitelist đó), mà là nới ngưỡng riêng cho request đã xác thực qua is_user_logged_in() kết hợp kiểm tra capability, đồng thời luôn giữ một cổng thoát hiểm không phụ thuộc plugin: ví dụ truy cập trực tiếp qua SSH để tắt plugin qua wp plugin deactivate, phòng trường hợp chính plugin khoá luôn cả đường vào wp-admin.

Quên whitelist REST API nội bộ. Nhiều plugin khác, ứng dụng mobile đi kèm site, hay webhook từ dịch vụ thanh toán gọi vào wp-json/ với tần suất cao nhưng hợp lệ. Nếu rate limiter áp cùng một ngưỡng cho mọi endpoint REST API mà không phân biệt route, các tích hợp hợp lệ này bị coi là tấn công và bị chặn: lỗi thường chỉ lộ ra khi một tính năng khác của site đột nhiên “hỏng” mà không ai liên hệ ngay tới plugin DDoS mới cài.

Tin header IP không xác thực. Đã nói chi tiết ở phần trước nhưng đủ nghiêm trọng để nhắc lại như một khoản mục riêng: bất kỳ đoạn code nào đọc thẳng X-Forwarded-For mà không kiểm tra REMOTE_ADDR có thuộc dải proxy tin cậy hay không đều là một lỗ hổng cho phép giả mạo IP hai chiều: vừa né được rate limiter, vừa có thể đổ oan cho IP người khác.

Log không giới hạn làm đầy ổ đĩa. Một cuộc tấn công kéo dài nhiều giờ, kết hợp với việc quên đặt giới hạn kích thước hoặc xoay vòng file log, có thể tự làm đầy ổ đĩa server độc lập với thiệt hại từ chính cuộc tấn công: biến một sự cố lẽ ra được kiểm soát tốt thành một sự cố ngừng dịch vụ toàn diện do hết dung lượng đĩa, ảnh hưởng cả những site khác cùng chia sẻ server nếu đang dùng shared hosting.

Kết luận

Giờ bạn có đủ kiến trúc và code để tự viết một plugin chống DDoS tầng ứng dụng chạy đúng dưới tải thật, từ chặn sớm, rate limit an toàn, đến leo thang lên tầng mạng khi cần. Điều duy nhất cần nhớ: plugin này luôn chỉ là một phần của hệ phòng thủ, không phải toàn bộ: tấn công tầng mạng vẫn cần xử lý ở CDN hoặc firewall, độc lập với code bạn vừa viết.