Rebounder Tech Blog

運用している当事者が書く、本番システムの記録。

レートリミッタのMapにmaxKeys上限が無いと、対策自体がメモリ枯渇で落ちる

公開 読了時間 約7分執筆: Rebounder 開発チーム(当該システムの運用当事者)

※本記事にはアフィリエイトリンクを含む場合があります。内容は広告の有無に影響されません。

結論

固定ウィンドウのレートリミッタは、詐称可能なkeyを退避しないMapで状態を持つと、対策自体がメモリ枯渇のDoSベクタになる。

結論

固定ウィンドウのレートリミッタが、詐称可能なkeyをそのままMapのキーにして状態を持つと、対策自体がメモリ枯渇のDoSベクタになる。 /api/sensors/switchbot/webhook ・/api/guide/feedback ・student-qaの3つの公開/準公開エンドポイントが共有していたFixedWindowRateLimiterは、client IPをkeyにしたwindowsというMapを一度も退避せずに持っており、毎リクエスト違うkeyを送るfloodにそのまま肥大化させられる作りだった。直し方は、MapのサイズそのものにmaxKeysという上限(既定50,000)を設け、上限到達時に2段階で間引くevictToBoundを新規key追加の前に走らせることだった。

症状

検証フェーズ(#243)のF13公開無認証webhookセキュリティレビューで見つかった。修正前のFixedWindowRateLimiterは次のような実装で、windowsはキー数に上限の無いMapだった。

// rate-limit.ts(修正前の要旨)
export class FixedWindowRateLimiter {
  private readonly windows = new Map<string, { windowStart: number; count: number }>();

  constructor(
    private readonly limit: number,
    private readonly windowMs: number,
  ) {}

  tryAcquire(key: string, nowMs: number): boolean {
    const state = this.windows.get(key);
    if (state !== undefined && nowMs - state.windowStart < this.windowMs) {
      if (state.count >= this.limit) return false;
      state.count += 1;
      return true;
    }
    this.windows.set(key, { windowStart: nowMs, count: 1 });
    return true;
  }
}

keyにはclientKeyFromHeadersが返す値、つまりx-forwarded-forの左端かx-real-ipが使われる。どちらもクライアントが自由に書き込める値であり、詐称は前提として想定されていた(モジュールのdocstring自身にも「XFFはclientが詐称しうる」と明記されている)。だが詐称そのものへの対処と、詐称された値が引き起こすMapの肥大化への対処は別の問題で、後者は手つかずのまま残っていた。攻撃者が毎リクエストでユニークなkeyを送り続ければ、windows.set(key, ...)が呼ばれるたびに新しいエントリが積まれ続け、期限切れになったエントリも次に同じkeyでアクセスが来ない限り自然には解放されない。per-instanceのメモリを食い尽くせば、Cloud Run上のインスタンスはOOMでクラッシュする。レート制限という防御そのものが、投入コストの低いDoSベクタになっていた。

原因

このレートリミッタは「同じkeyへの試行回数を数える」という発想だけで作られており、keyの集合そのものが攻撃者にコントロールされ得るという前提が設計に入っていなかった。IP単位の制限は「悪意のある1クライアントを抑える」ことを目的にしているが、windowsというMapの実体は「見たことのあるkeyをすべて憶えておく」というだけの構造で、key自体の信頼性もkeyの総数も検証していない。詐称可能な値をkeyに採用した時点で、Mapのサイズは攻撃者の送信回数に比例して増え続け得るという副作用が生まれる。レート制限の閾値(limit・windowMs)をいくら厳しくしても、この副作用には効かない。対象が「1keyあたりの頻度」であって「key数そのもの」ではなかったことが、見落としの構造だった。

直し方

maxKeysという引き当て数の上限(既定DEFAULT_MAX_KEYS = 50_000)を追加し、新規keyを追加する前に上限へ達していないかを確認するようにした。達していればevictToBoundを呼ぶ。

// rate-limit.ts(修正後の要旨)
export const DEFAULT_MAX_KEYS = 50_000;

export class FixedWindowRateLimiter {
  private readonly windows = new Map<string, { windowStart: number; count: number }>();

  constructor(
    private readonly limit: number,
    private readonly windowMs: number,
    private readonly maxKeys: number = DEFAULT_MAX_KEYS,
  ) {}

  tryAcquire(key: string, nowMs: number): boolean {
    const state = this.windows.get(key);
    if (state !== undefined && nowMs - state.windowStart < this.windowMs) {
      if (state.count >= this.limit) return false;
      state.count += 1;
      return true;
    }
    if (state === undefined && this.windows.size >= this.maxKeys) {
      this.evictToBound(nowMs);
    }
    this.windows.set(key, { windowStart: nowMs, count: 1 });
    return true;
  }

  private evictToBound(nowMs: number): void {
    for (const [k, s] of this.windows) {
      if (nowMs - s.windowStart >= this.windowMs) this.windows.delete(k);
    }
    if (this.windows.size < this.maxKeys) return;
    const oldestFirst = Array.from(this.windows.entries()).sort(
      (a, b) => a[1].windowStart - b[1].windowStart,
    );
    const removeCount = this.windows.size - this.maxKeys + 1;
    for (const [k] of oldestFirst.slice(0, removeCount)) {
      this.windows.delete(k);
    }
  }
}

evictToBoundは2段階で間引く。まず期限切れ(windowMs経過済み)のウィンドウを総ざらいで削除する。floodで送られてくるkeyの大半はワンショットで、その後同じkeyで再アクセスが来ないため、ここでまとめて回収できる。期限切れ分を消してもなお上限に達している場合(=同一ウィンドウ内でmaxKeysを超えるユニークkeyが来ている状態)は、windowStartが最も古いものから、新規1件分の空きができるまで間引く。

既存の呼び出し側は3箇所(/api/sensors/switchbot/webhook・/api/guide/feedback・student-qa)あったが、maxKeysはコンストラクタの既定値つき任意引数としたため、呼び出し側のコードは無変更のまま硬化された。limit・windowMs・key独立・reset()の既存挙動も変えていない。追加した単体テストでは、同一ウィンドウでmaxKeysを超えるユニークkeyを流し込んでもsize()がmaxKeys以内に収まること、上限到達後に期限切れウィンドウが一掃されること、間引きの導入後も通常のlimit判定(同一keyでの超過)が壊れていないことを確認している。

なお、この対策はvolume自体の遮断を目的にしていない。per-instanceのメモリ枯渇という副作用を塞ぐだけで、大量のリクエストそのものを止めるのはWAFやCloud Armorのような前段のレイヤーに委ねている。

再発防止

evictToBoundの単体テストは、同一ウィンドウでのflood(ユニークkeyをmaxKeys超で投入)と、期限切れ後の新規key追加の両方を固定のnowMsで決定的に検証しており、この境界を外す変更が入れば即座に落ちる構成になっている。あわせてsize()を運用監視用に公開したため、本番でMapの実際のサイズを追跡できるようにもなっている。

よくある質問

Q1レートリミッタのMapにkey上限が無いと何が起きますか?

client IPのような詐称可能な値をkeyにしてMapに状態を持たせていると、攻撃者が毎リクエストで違うkeyを送るだけでMapのエントリ数が増え続け、上限が無ければper-instanceのメモリを食い尽くしてインスタンスがOOMで落ちる。レート制限という対策自体が別のDoSベクタになる。

Q2keyを詐称できないようにすれば直りませんか?

X-Forwarded-Forの左端のようなkeyは原理的にクライアントが書き込める値で、信頼できる経路に変えても詐称の余地を完全には消せない。したがって『詐称されても壊れない』側、つまりMapのサイズそのものを上限化する対策が要る。

Q3maxKeysに達したら何を間引きますか?

まず期限切れ(windowMs経過)のウィンドウを一掃する。floodの大半はここで回収できる。それでも上限に達しているなら、最古のwindowStartを持つキーから新規1件分の空きができるまで間引く。

Q4この変更で既存の挙動は変わりませんか?

変わらない。maxKeysはコンストラクタの既定値つきの任意引数にしたため、呼び出し側を直さなくても既存の3箇所の呼び出しは無変更で硬化される。ウィンドウ・limit・key独立・resetの挙動もそのまま。

Q5これでflood自体は止まりますか?

止まらない。これはper-instanceのメモリ枯渇を防ぐ防御であって、リクエストの物量そのものを遮断するのはWAFやCloud Armorのような前段のレイヤーが担う。レートリミッタ単体を最後の砦にはしていない。

確認した環境

  • Next.js ^16.0.0(apps/web)/ Cloud Run デプロイ
  • 2026-06-01 commit 1f16a400(PR #436)。検証フェーズ #243 のF13公開無認証webhookセキュリティレビューで発見・修正

この記事の根拠

  • TypeScriptファイル 1〜100行目コミット 1f16a40
  • TypeScriptファイル 49〜81行目コミット 1f16a40

本文の主張は、上の記録に書かれていることだけです。運用しているリポジトリは非公開のため リンクは張れませんが、どのファイルの何行目を、どのコミット時点で見て書いたかは 記事ごとに残しています。推測で書いた箇所はありません。