iT邦幫忙

2026 iThome 鐵人賽

DAY 16
0
Software Development

從 Laravel 到 Spring Boot:30 天打造縮網址服務系列 第 16 篇

Day16 - Rate Limiting 實作(防暴力產生短網址)

  • 分享至 

  • xImage
  •  

要擋的是什麼

大綱上這篇的標題是「防暴力產生短網址」,但寫到這裡要先誠實面對一件事:Day13 之後,建立短網址的 POST /api/links 已經需要 API Token 了,外人根本沒辦法建立短網址。那還需要限流嗎?

需要,而且要擋的不只一個地方:

  1. 建立短網址 POST /api/links:外人進不來,但 Token 有可能外洩,或是自己寫的腳本有 bug,在迴圈裡不停呼叫。一個短網址服務正常使用時,一分鐘建立 10 個已經很多了
  2. 重定向 GET /{code}:這支是公開的,完全不需要登入,才是真正對外暴露的入口。Day14 說過短碼要「猜不到」,但如果不限制請求頻率,別人可以寫腳本每秒猜上千次;Day15 也提過「一直訪問不存在的短碼」會繞過快取、每次都打到資料庫

所以規則分成兩條:

路徑 依據什麼計數 限制
POST /api/links 登入身分(ownerId) 每分鐘 10 次
GET /{code} 來源 IP 每分鐘 60 次

重定向的限制刻意放寬:公司、學校裡的很多人常常共用同一個對外 IP,限制太緊會誤傷正常使用者。每分鐘 60 次對真人來說綽綽有餘,對掃描腳本來說則是從「每秒上千次」降到「每秒一次」。

以 Day14 的數字來算,假設服務裡有 100 萬個短網址,隨便猜一個短碼猜中的機率是五萬六千八百分之一。不限流的腳本每秒猜 1000 次,一天可以猜中一千多個;限流之後一個 IP 一天最多猜 86,400 次,大約只能猜中一兩個。限流沒辦法讓猜測變成不可能,但能讓代價高到不划算。

Laravel 的做法:throttle

Laravel 的限流是內建的,先在 AppServiceProvider 定義規則:

// app/Providers/AppServiceProvider.php
public function boot(): void
{
    RateLimiter::for('links', function (Request $request) {
        return Limit::perMinute(10)->by($request->user()?->id ?: $request->ip());
    });

    RateLimiter::for('redirect', function (Request $request) {
        return Limit::perMinute(60)->by($request->ip());
    });
}

再套到路由上:

Route::post('/links', [LinkController::class, 'store'])->middleware(['auth:sanctum', 'throttle:links']);
Route::get('/{code}', RedirectController::class)->middleware('throttle:redirect');

超過限制時,Laravel 自動回 429 Too Many Requests,並且在回應裡加上 Retry-After header,告訴呼叫端幾秒後可以再試。背後的計數存在 Laravel 的 Cache 裡,CACHE_STORE=redis 的話就是存在 Redis。

Spring Boot:沒有內建,自己來

Spring Boot 本身沒有提供 Rate Limiting。常見的選擇有:

  • Bucket4j:專門做限流的 Java 函式庫,支援多種演算法,也能把計數存在 Redis
  • Resilience4j 的 RateLimiter:比較偏向「保護自己不要呼叫外部服務太頻繁」,限制的是整個應用程式,不是針對每個使用者
  • 自己用 Redis 實作

這篇選擇自己實作。Laravel 的 throttle 背後其實也就是「在快取裡計數、設定過期時間」,核心邏輯很短;Day15 已經接好 Redis 跟 StringRedisTemplate,不用多裝任何東西;自己寫一次,也最能理解限流在做什麼。之後如果需要更複雜的演算法,再換成 Bucket4j 就好。

固定視窗演算法

最簡單的限流演算法叫固定視窗(Fixed Window):把時間切成一格一格的視窗(例如每 60 秒一格),每個使用者在每一格裡有一個計數器,每來一個請求就 +1,超過上限就擋。下一格開始時,計數器從 0 重新開始。

用 Redis 實作只需要兩個指令:

  • INCR:計數器 +1,並回傳 +1 之後的值。Day15 說過它是原子操作,同時有兩個請求進來也不會算錯
  • EXPIRE:設定計數器的過期時間,視窗結束後 Redis 會自動刪掉它,不用自己清理
@Component
class RateLimiter {

    private final StringRedisTemplate redis;

    RateLimiter(StringRedisTemplate redis) {
        this.redis = redis;
    }

    /**
     * 回傳這次請求之後,這個視窗裡已經累積的次數
     */
    long hit(String name, String identity, int windowSeconds) {
        long window = Instant.now().getEpochSecond() / windowSeconds;
        String key = "rate:" + name + ":" + identity + ":" + window;

        Long count = redis.opsForValue().increment(key);
        if (count != null && count == 1) {
            redis.expire(key, Duration.ofSeconds(windowSeconds));
        }
        return count == null ? 0 : count;
    }
}

key 會長得像 rate:links:user:1:29581440,最後那串數字是「現在是第幾個視窗」。

這裡把視窗編號放進 key 有一個好處:INCR 跟 EXPIRE 是兩個分開的指令,如果剛好在兩者之間應用程式當掉,這個 key 就不會有過期時間。但因為 key 裡帶著視窗編號,下一個視窗會用新的 key,舊的那個不會再被使用,頂多在 Redis 裡多留一個沒用的 key,不會讓某個使用者被永久擋住。

固定視窗有一個已知的缺點:視窗邊界的突發流量。限制每分鐘 10 次,使用者可以在 0:59 送 10 次、1:00 再送 10 次,兩秒內送出 20 次,因為剛好跨了兩個視窗。要解決這個問題需要滑動視窗(Sliding Window)或令牌桶(Token Bucket)這類演算法,Bucket4j 用的就是令牌桶。對這個專案來說,偶爾放過兩倍的突發流量完全可以接受,固定視窗就夠了。

@RateLimit:兌現 Day12 的伏筆

Day12 比較 Filter 跟 Interceptor 的時候說過:Interceptor 站在 HandlerMapping 後面,拿得到目標 Controller 方法,連方法上標了什麼註解都讀得到。今天就用上這個能力。

先定義一個註解:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@interface RateLimit {
    String name();
    int requests();
    int windowSeconds() default 60;
}

@Retention(RUNTIME) 很重要:註解預設只會留在編譯後的 class 檔裡,程式執行時讀不到。加上 RUNTIME,Interceptor 才能在執行時讀到它。

標在需要限流的 Controller 方法上:

// LinkController
@PostMapping
@RateLimit(name = "links", requests = 10)
ResponseEntity<LinkResponse> store(@Valid @RequestBody CreateLinkRequest request,
                                   @AuthenticationPrincipal Long ownerId) { ... }

// RedirectController
@GetMapping("/{code:[0-9A-Za-z]{4,16}}")
@RateLimit(name = "redirect", requests = 60)
ResponseEntity<Void> redirect(@PathVariable String code) { ... }

效果跟 Laravel 在路由上加 ->middleware('throttle:links') 很像,差別在 Laravel 標在路由上,Spring 標在方法上,限制規則直接寫在註解參數裡,不用另外到 Service Provider 定義。

Interceptor:讀註解、計數、擋請求

@Component
class RateLimitInterceptor implements HandlerInterceptor {

    private final RateLimiter rateLimiter;

    RateLimitInterceptor(RateLimiter rateLimiter) {
        this.rateLimiter = rateLimiter;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        if (!(handler instanceof HandlerMethod method)) {
            return true;
        }
        RateLimit limit = method.getMethodAnnotation(RateLimit.class);
        if (limit == null) {
            return true;   // 沒標註就不限制
        }

        long count = rateLimiter.hit(limit.name(), identify(request), limit.windowSeconds());
        long remaining = Math.max(0, limit.requests() - count);

        response.setHeader("X-RateLimit-Limit", String.valueOf(limit.requests()));
        response.setHeader("X-RateLimit-Remaining", String.valueOf(remaining));

        if (count > limit.requests()) {
            long retryAfter = limit.windowSeconds() - Instant.now().getEpochSecond() % limit.windowSeconds();
            throw new RateLimitExceededException(retryAfter);
        }
        return true;
    }

    private String identify(HttpServletRequest request) {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (auth != null && auth.getPrincipal() instanceof Long ownerId) {
            return "user:" + ownerId;
        }
        return "ip:" + request.getRemoteAddr();
    }
}

幾個重點:

  1. identify() 就是 Laravel 的 by($request->user()?->id ?: $request->ip()):有登入身分就用身分,沒有就用 IP。Day13 的 ApiTokenFilter 驗證成功時,會把 ownerId 放進 SecurityContext,這裡直接拿出來用。判斷時用 instanceof Long 而不是 auth != null,是因為 Spring Security 對沒登入的請求也會放一個「匿名身分」進去,auth 不會是 null,它的 principal 是字串 "anonymousUser"。如果只檢查 auth != null,所有沒登入的人都會被當成同一個使用者,共用同一個計數器
  2. header 在 preHandle 裡設定:Day12 說過,@RestController 的回應在 postHandle 執行前就已經寫出去了,這時候再加 header 不會有效果。preHandle 在 Controller 執行前,回應還沒送出,這時候設定的 header 一定會生效
  3. 超過限制就丟例外:Day12 也說過,Filter 裡丟的例外 @RestControllerAdvice 攔不到。但 Interceptor 在 DispatcherServlet 裡面,這裡丟的例外會被 Day10 的 GlobalExceptionHandler 接住,所以可以放心用丟例外的方式處理

註冊到 Day12 的 WebConfig,這次不用 addPathPatterns(),因為要不要限流已經由註解決定了:

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(apiTimingInterceptor)
        .addPathPatterns("/api/**");
    registry.addInterceptor(rateLimitInterceptor);
}

429 也要是統一格式

class RateLimitExceededException extends RuntimeException {

    private final long retryAfterSeconds;

    RateLimitExceededException(long retryAfterSeconds) {
        super("請求太頻繁,請稍後再試");
        this.retryAfterSeconds = retryAfterSeconds;
    }

    long retryAfterSeconds() {
        return retryAfterSeconds;
    }
}

在 Day10 的 GlobalExceptionHandler 加一個處理方法:

@ExceptionHandler(RateLimitExceededException.class)
ResponseEntity<ErrorResponse> handleRateLimit(RateLimitExceededException ex) {
    return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
        .header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.retryAfterSeconds()))
        .body(new ErrorResponse(ex.getMessage(), List.of()));
}

測試一下,連續建立 11 個短網址:

for i in $(seq 1 11); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:8080/api/links \
    -H "Authorization: Bearer $SHORTENER_API_TOKEN" \
    -H "Content-Type: application/json" \
    -d '{"url":"https://example.com"}'
done
# 200 × 10
# 429

第 11 次回 429,回應內容是 Day10 的統一格式,header 裡帶著 Retry-After:

HTTP/1.1 429
Retry-After: 37
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0

{"message":"請求太頻繁,請稍後再試","errors":[]}

如果 Redis 掛了,rateLimiter.hit() 會丟連線例外,所有標了 @RateLimit 的 API 都會跟著回 500,連重定向都不能用。這是一個取捨:限流是保護機制,不是核心功能,為了它讓整個服務停擺不太划算。比較好的做法是接住 Redis 的連線例外、記一行 log,然後放行(fail open)。這個專案的 Redis 跟應用程式跑在同一台 Pi 上,Redis 掛了通常代表整台機器都有問題,先記下這個取捨,不另外處理。

本機測試都正常,上線才出事

到這裡,本機用 curl 測試,建立短網址跟重定向的限流都正常運作。

但限流這件事有個特性:它只有在「有人不懷好意」或「有很多人同時使用」的時候才重要,而這兩種情況在本機測試時都不會出現。限流的邏輯本身很簡單,真正會出錯的是兩個前提:哪些請求有被計數,跟用什麼判斷「同一個人」。這兩件事寫錯的時候不會有任何錯誤訊息,本機測試也一切正常,要等到上線、真的被攻擊時才會發現限流根本沒擋住。

雷點一:沒通過認證的請求,根本沒被計數

想像有人拿到了 API 的網址,想要猜出 API Token。他會一直送 POST /api/links,每次換一組 Token。這種請求應該要被限流擋下來吧?

實際上,這些請求完全不會經過 RateLimitInterceptor。

回到 Day12 那張請求生命週期圖:Filter 在 DispatcherServlet 前面,Interceptor 在後面;Day13 又說過,Spring Security 本身就是一整串 Filter。所以 Token 錯誤的請求,在 Spring Security 的 Filter 那一站就被 JsonAuthenticationEntryPoint 直接回 401 了,根本走不到 Interceptor:

請求(Token 錯誤)
   ↓
Spring Security Filter 鏈  → 401,請求到這裡就結束
   ↓(到不了)
DispatcherServlet → RateLimitInterceptor → Controller

只有通過認證的請求才會被計數,而通過認證的,正好是最不需要擔心的那一群。

Laravel 預設其實也是先認證、再限流(框架內建的 Middleware 優先順序把認證排在前面),但兩者都是 Middleware,排在同一條鏈上,想調整順序就調整得了。Spring 的 Filter 跟 Interceptor 則是兩層不同的東西,Interceptor 永遠排在所有 Filter 後面,不管怎麼設定,都沒辦法讓 Interceptor 比 Spring Security 先執行。

那這個專案需要處理嗎?分析一下:Day13 的 API Token 是用 openssl rand -base64 32 產生的 32 bytes 亂數,可能的組合有 2²⁵⁶ 種,就算每秒猜十億次,猜到宇宙毀滅也猜不中。對這種 Token 來說,不限制猜測次數也沒有實際風險。

但如果之後開放多使用者、做了「帳號密碼登入」的 API,情況就完全不同了:使用者自己設定的密碼常常很弱,登入 API 一定要限制嘗試次數。到時候限流就不能放在 Interceptor,要改寫成 Filter,並且用 addFilterBefore() 插在 Spring Security 的認證 Filter 前面(跟 Day13 插入 ApiTokenFilter 的方式一樣),才能在認證之前先計數。

重點是:限流放在哪一層,決定了哪些請求會被計數。放在 Interceptor,就只擋得到「已經通過 Spring Security」的請求。

雷點二:放在反向代理後面,所有人的 IP 都一樣

重定向是用 request.getRemoteAddr() 取得 IP 來計數的。本機測試時,拿到的是 127.0.0.1,看起來很正常。

但這個服務最後會部署在 Raspberry Pi 上,透過 Cloudflare Tunnel 對外開放(Day29)。這時候請求的路徑是:

使用者 → Cloudflare → cloudflared(Tunnel)→ Spring Boot

對 Spring Boot 來說,直接連進來的永遠是 cloudflared,所以 getRemoteAddr() 拿到的永遠是 cloudflared 的 IP,不是使用者的。結果就是全世界的訪客共用同一個計數器,一分鐘總共只能重定向 60 次,只要一個人刷一下,所有人都會拿到 429。

真正的使用者 IP 會由 Cloudflare 放在 X-Forwarded-For 這類 header 裡傳過來。Laravel 處理這件事的方式是 trustProxies():

// bootstrap/app.php
->withMiddleware(function (Middleware $middleware) {
    $middleware->trustProxies(at: '*');
})

設定之後,$request->ip() 就會改從 header 裡拿真正的使用者 IP。Spring Boot 的對應設定是:

server:
  forward-headers-strategy: native

設定成 native 之後,Tomcat 會讀取 X-Forwarded-For,request.getRemoteAddr() 就會回傳真正的使用者 IP,Interceptor 的程式碼一行都不用改。

但這裡有一個反方向的陷阱:不能無條件相信 X-Forwarded-For。header 是客戶端可以自己填的,如果應用程式直接暴露在網路上,又無條件相信這個 header,攻擊者每次請求都填一個不同的假 IP,限流就完全失效了。所以只能相信「來自自己的代理伺服器」的請求所帶的 header。Tomcat 預設只相信來自內網 IP(10.x、192.168.x、172.16.x 到 172.31.x、127.0.0.1 這些)的代理,剛好符合「cloudflared 跟 Spring Boot 跑在同一台 Pi 或同一個 Docker 網路裡」的部署方式。Laravel 的 trustProxies(at: '*') 則是相信所有來源,只有在確定應用程式不會被直接連線時才能這樣設定。

這個設定要等到 Day29 真的接上 Cloudflare Tunnel 才能實際驗證,先在 application-prod.yml 裡加上,到時候再確認 log 裡印出來的 IP 是不是真的使用者 IP。

明天 Day17 進到排程任務:定期清理過期的短網址,順便把 Day15 暫存在 Redis 裡的點擊數寫回資料庫。


上一篇
Day15 - Redis 快取整合 vs Laravel Cache(熱門短網址快取)
下一篇
Day17 - 排程任務 Spring Scheduler vs Laravel Scheduler(過期連結清理)
系列文
從 Laravel 到 Spring Boot:30 天打造縮網址服務 共 17 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言