TLS 1.3 0-RTT(Early Data)のリスク|リプレイ攻撃とnginx・Node.js実装ガイド
- TLS 1.3 0-RTT(early data)は再訪問の1 RTT短縮と引き換えに、リプレイ攻撃の受け口になる。
- 静的GET中心のCDN配信なら恩恵が大きい一方、ログイン・決済・状態変更が同一ホストで動くなら nginx で
ssl_early_data off;、Node.js ではmaxEarlyDataSize: 0が安全なデフォルト。 - 0-RTTを残すなら $ssl_early_data / X-TLS-Early-Data で非冪等リクエストを425拒否し、Anti-Replay トークンとべき等性設計をセットで実装する。
TLS 1.3 0-RTT(Early Data)とは
TLS 1.3(RFC 8446)では、従来のTLS 1.2と比べてハンドシェイクが短くなった。初回接続は1-RTT、セッション再開(resumption)時にはクライアントがハンドシェイクの応答を待たずにアプリケーションデータを送れる0-RTT(ゼロラウンドトリップ)モードが追加された。この先送りされる暗号化データを early data(早期データ)と呼ぶ。
【通常の TLS 1.3 再開(1-RTT resumption)】
Client ── ClientHello + PSK ──────────────────► Server
Client ◄── ServerHello + Finished ──────────── Server
Client ── Finished + HTTP Request ────────────► Server ← ここで初めてHTTP
【0-RTT early data あり】
Client ── ClientHello + PSK + HTTP Request ───► Server ← ハンドシェイクと同時
Client ◄── ServerHello + Finished + Response ─ Server
ブラウザが再訪問するECサイトやAPIゲートウェイでは、TTFB(Time To First Byte)を数ms〜数十ms削減できる。HTTP/3(QUIC)でも同様の概念があり、HTTP/2とHTTP/3の比較記事でも触れたように、モバイル回線の再訪問では体感差が出やすい。
一方で、IETFのTLS 1.3仕様自体が 0-RTTデータはリプレイされる可能性がある と明記している。これは実装バグではなく、プロトコル設計上のトレードオフだ。性能を取るか、リプレイ耐性を取るかをインフラとアプリケーションの両方で設計しなければならない。
1-RTT・0-RTT・フルハンドシェイクの比較
| 接続パターン | 往復(RTT) | early data | 典型レイテンシ感(RTT 80ms) |
|---|---|---|---|
| フルハンドシェイク(初回) | 1-RTT(TLS 1.3) | 不可 | 〜80ms + 証明書処理 |
| セッション再開(1-RTT) | 1-RTT | 不可(ハンドシェイク後に送信) | 〜80ms |
| 0-RTT resumption | 0-RTT(データ先送り) | 可 | データ送信がハンドシェイクと並行 |
初回訪問者にはPSK(Pre-Shared Key)がないため0-RTTは使えない。CDNキャッシュヒット率が高く、リピーターが多いサイトほど恩恵が大きい。ログイン後のSPAやAPIばかりのサービスでは、効果測定してから有効化を判断すべきだ。
PSKとセッションチケットの仕組み
0-RTTの前提は、前回の接続で交換したセッション情報の再利用だ。
- 初回フルハンドシェイク完了後、サーバーは NewSessionTicket(または同等のチケット)をクライアントへ渡す
- クライアントはチケットと派生鍵(PSK)を保存する
- 再訪問時、ClientHelloに pre_shared_key 拡張と early_data 拡張を付け、HTTPリクエストを暗号化して同時送信する
- サーバーはチケットを検証し、妥当ならハンドシェイク完了前にearly dataを復号してアプリケーションへ渡す
nginxでは ssl_session_cache と ssl_session_tickets がこの再利用を支える。チケットの有効期限(通常は数時間〜1日)が切れると0-RTTは使えず、再び1-RTT以上に戻る。
Early Data リプレイ攻撃 — 何が起きるか
リプレイ攻撃(Replay Attack) とは、正当な通信を傍受し、後から同じデータを再送してサーバーやクライアントを騙す手法だ。TLS 1.3の0-RTTでは、early dataに含まれたHTTPリクエスト(またはQUICフレーム)が、別のTCP/QUIC接続・別の時刻に攻撃者によって再送されても、サーバーが「新しい正当なリクエスト」と区別できない場合がある。
攻撃シナリオの具体例
シナリオA:静的GETのリプレイ(影響小)
- ユーザーが
GET /assets/logo.pngを0-RTTで送信 - 攻撃者がパケットをコピーし、1時間後に同じearly dataを再送
- サーバーは再度200と画像ボディを返す
キャッシュ可能な静的リソースであれば、副作用はほぼない。CDNのキャッシュヒットと実質同じだ。
シナリオB:状態変更APIのリプレイ(影響大)
- ユーザーが
POST /api/transferに{ "to": "attacker", "amount": 10000 }を0-RTTで送信(誤設定でearly dataが許可されている) - サーバーが処理し送金完了
- 攻撃者が傍受した0-RTTパケットを再送
- サーバーが再度送金を実行 → 二重送金
このシナリオは べき等性(Idempotency)設計 が未整備だと致命傷になる。0-RTTは「ネットワーク層で再送が起きうる」ことを前提にアプリを組む必要がある。
シナリオC:ログインやセッション確立のリプレイ
POST /loginがearly dataで受理される(本来ありえない設定)- リプレイによりセッション確立処理が重複、またはレート制限・ロックアウトを迂回される
認証エンドポイントは常に0-RTT対象外とするのが定石だ。
なぜTLS層だけでは防げないのか
TLS 1.3の0-RTTでは、サーバーは「このearly dataは以前見たコピーか?」を暗号プロトコル単体では完全には判定できない。理由の要点は次のとおり。
| 観点 | 説明 |
|---|---|
| 接続ごとの独立性 | リプレイは新しいTCP/QUIC接続で行われる。サーバーは正当な再訪問と攻撃的再送を区別しにくい |
| 複数拠点 | 攻撃者が再送するタイミングと、正当なユーザーの再訪問が異なるサーバーインスタンスに当たる可能性がある |
| 仕様上の割り切り | RFC 8446はアプリケーション層でのAnti-Replayを求める。TLSは性能優先の道を開いた |
したがって「TLS 1.3だからリプレイ安全」は誤り。0-RTTを有効にした時点で、アプリケーションまたはリバースプロキシで防御設計が必須になる。
early dataに載せたリクエストは、少なくとも1回は意図せず再実行されうると想定せよ。決済・在庫・投票・メール送信など非冪等な操作を0-RTTで受け付ける設計は避ける。
リプレイが現実的に成立する条件
すべてのサイトが0-RTTリプレイの標的になるわけではない。攻撃者にとってコストが見合う条件を整理する。
- サーバーがearly dataを受理している(
ssl_early_data on、CDNで0-RTT有効など) - 再送されたリクエストがサーバーで副作用を持つ(POST、状態変更GET、GraphQL mutation)
- 傍受可能な経路がある(同一LAN、不正AP、BGPヒジャック、データセンター内の侵害者など)
- Anti-Replayが未実装(一度きりトークン、リプレイキャッシュ、Idempotency-Keyなし)
社内VPN上の管理画面や、決済と静的配信をホスト分離している構成では、リスクプロファイルは大きく異なる。同一オリジンにAPIと静的ファイルを載せているモノリスは特に注意が必要だ。
nginxでの ssl_early_data 設定
nginx(1.13.0以降、HTTP/2のearly data対応は1.15.4以降)では ssl_early_data ディレクティブで0-RTTを制御する。デフォルトは off だが、HTTP/3やパフォーマンスチューニングの記事を真似て on にしたまま放置している例が見られる。
0-RTTを無効化する(推奨デフォルト)
http {
# グローバルで無効化 — 迷ったらこちら
ssl_early_data off;
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:SSL:50m;
ssl_session_timeout 1d;
ssl_session_tickets on;
# 明示的に off(httpブロックで off なら省略可)
ssl_early_data off;
location / {
proxy_pass http://127.0.0.1:8080;
}
}
}
ssl_early_data off; のとき、クライアントが0-RTTでデータを送っても、nginxはハンドシェイク完了まで待ってからリクエストをアプリケーションへ渡す。リプレイ表面はここでほぼ閉じる。
0-RTTを有効化する場合のnginx設定
どうしてもレイテンシを取りにいく場合の例。必ず後述の $ssl_early_data ガードとセットにすること。
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name cdn.example.com;
ssl_certificate /etc/ssl/cdn.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/cdn.example.com/privkey.pem;
ssl_protocols TLSv1.3;
ssl_early_data on;
# early data 中はリクエストをバッファしない(デフォルト推奨)
ssl_buffer_size 4k;
# Anti-Replay: early data では GET/HEAD/OPTIONS のみ許可
if ($ssl_early_data = 1) {
set $early_block 0;
if ($request_method !~ ^(GET|HEAD|OPTIONS)$) {
set $early_block 1;
}
if ($early_block = 1) {
return 425; # Too Early (RFC 8470)
}
}
location / {
root /var/www/static;
add_header Cache-Control "public, max-age=3600";
}
# APIは別 server ブロックで ssl_early_data off 推奨
}
| ディレクティブ | 意味 |
|---|---|
ssl_early_data on | off | TLS 1.3 0-RTTの受理可否 |
$ssl_early_data | リクエストがearly data由来なら 1、それ以外は空 |
return 425 | HTTP 425 Too Early — early dataを拒否する標準的ステータス(RFC 8470) |
ssl_buffer_size | early data到着時のバッファ。小さくするとメモリ効率が良い |
HTTP/3(QUIC)との併用
HTTP/3を有効にしている場合、QUICレイヤでも0-RTTが使われる。
server {
listen 443 quic reuseport;
listen 443 ssl;
http2 on;
http3 on;
ssl_early_data on; # QUIC 0-RTT も影響
add_header Alt-Svc 'h3=":443"; ma=86400';
ssl_certificate /etc/ssl/cdn.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/cdn.example.com/privkey.pem;
location / {
root /var/www/static;
add_header Cache-Control "public, max-age=86400";
}
}
APIオリジンでは ssl_early_data off とし、静的CDNホストだけ on にするホスト分離が運用しやすい。
Node.js での 0-RTT 制御と Early Data 検知
nginx の背後に Node.js アプリを置く構成では、TLS 終端の位置で責任分界が変わる。nginx が TLS を終端する場合、Node には $ssl_early_data 相当の情報を カスタムヘッダーで渡す必要がある。Node 自身が https.createServer で TLS を終端する場合は、OpenSSL の early data API を直接扱う。
nginx → Node.js へ early data フラグを伝える
map $ssl_early_data $http_x_tls_early_data {
default "";
1 "1";
}
server {
listen 443 ssl http2;
server_name api.example.com;
ssl_early_data on;
location / {
proxy_set_header X-TLS-Early-Data $http_x_tls_early_data;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_pass http://127.0.0.1:3000;
}
}
Node 側ではこのヘッダーを信頼する前に、nginx 以外から直接到達できないこと(ファイアウォールで 3000 番を閉じる)を確認する。ヘッダーは内部ネットワーク専用の信号だ。
Express ミドルウェア:非冪等メソッドを 425 で拒否
// middleware/rejectEarlyData.js
const NON_IDEMPOTENT = new Set(["POST", "PUT", "PATCH", "DELETE"]);
function rejectEarlyData(req, res, next) {
const isEarly = req.get("X-TLS-Early-Data") === "1";
if (isEarly && NON_IDEMPOTENT.has(req.method)) {
res.set("Retry-After", "0");
return res.status(425).send("Too Early");
}
next();
}
module.exports = { rejectEarlyData };
// app.js
const express = require("express");
const { rejectEarlyData } = require("./middleware/rejectEarlyData");
const { antiReplayToken } = require("./middleware/antiReplayToken");
const app = express();
app.use(express.json());
app.use(rejectEarlyData);
app.use("/api", antiReplayToken);
app.get("/api/health", (req, res) => {
res.json({ ok: true });
});
app.listen(3000, "127.0.0.1");
Node.js 直接 TLS 終端(early data サイズ制限)
Node が TLS を終端する場合、tls.createSecureContext と ALPN で HTTP/1.1 または h2 を選ぶ。OpenSSL 3 系の Node 20+ では、early data の受理は セッション再開時にクライアントが送ってきたデータ量を maxEarlyDataSize で制限できる(利用可能な場合は 0 にして無効化が最も安全)。
// server-tls.js — API サーバーでは early data を実質無効化
const fs = require("fs");
const https = require("https");
const express = require("express");
const app = express();
app.use(express.json());
app.post("/api/orders", (req, res) => {
res.status(201).json({ id: "ord_123" });
});
const options = {
key: fs.readFileSync("/etc/ssl/api.example.com/privkey.pem"),
cert: fs.readFileSync("/etc/ssl/api.example.com/fullchain.pem"),
minVersion: "TLSv1.3",
sessionTimeout: 300,
sessionIdContext: "api.example.com",
// early data を受けない: maxEarlyDataSize を 0 に(Node/OpenSSL バージョン要確認)
maxEarlyDataSize: 0,
};
https.createServer(options, app).listen(443, () => {
console.log("HTTPS listening on 443 (0-RTT disabled via maxEarlyDataSize=0)");
});
maxEarlyDataSize: 0 が環境で効かない場合は、リクエスト到達前に nginx で ssl_early_data off とする二重防御が確実だ。
Fastify + @fastify/helmet との併用
Fastify でも同様のミドルウェアパターンを preHandler フックで実装できる。
const fastify = require("fastify")({ logger: true });
const NON_IDEMPOTENT = new Set(["POST", "PUT", "PATCH", "DELETE"]);
fastify.addHook("preHandler", async (request, reply) => {
const isEarly = request.headers["x-tls-early-data"] === "1";
if (isEarly && NON_IDEMPOTENT.has(request.method)) {
reply.header("Retry-After", "0");
return reply.code(425).send({ error: "Too Early" });
}
});
fastify.get("/api/status", async () => ({ status: "ok" }));
fastify.listen({ port: 3000, host: "127.0.0.1" });
Cloudflare経由の場合
Cloudflareダッシュボードの Network → 0-RTT Connection Resumption がONだと、エッジで0-RTTが有効になる。オリジンnginxの設定と独立しているため、EdgeとOriginの両方を確認すること。キャッシュ可能なGET中心ならEdge 0-RTTの恩恵は大きいが、動的APIを同一ホストで配信しているとリスクが混ざる。
Anti-Replay トークン(反リプレイトークン)の設計
0-RTT を部分的に許可する場合、アプリケーション層の Anti-Replay トークンが最後の防衛線になる。TLS の early data だけでは「同じリクエストが2回届いた」ことを検知できないため、一度きり使える nonce(ワンタイムトークン) または リクエスト指紋の短期キャッシュで重複を弾く。
トークンのライフサイクル
- クライアントが
GET /api/anti-replay-tokenを通常の1-RTT接続で呼び、サーバーが UUID を発行して Redis に保存(TTL 30〜120秒) - 状態変更リクエストのヘッダー
X-Anti-Replay-Token: <uuid>に載せる - サーバーは Redis で
GET→ 存在すればDEL(原子的に consume)して処理続行 - 同じトークンがリプレイで再送されたら Redis にキーがなく 409 Conflict または 425 Too Early を返す
early data で届いたリクエストは、トークン検証前に 425 で落とすか、トークン必須にして「トークンなし early POST」を拒否する二段構えが安全だ。
Redis による Anti-Replay トークン実装(Node.js 完全例)
// middleware/antiReplayToken.js
const { randomUUID } = require("crypto");
const Redis = require("ioredis");
const redis = new Redis(process.env.REDIS_URL);
const TOKEN_TTL_SEC = 60;
const TOKEN_PREFIX = "anti-replay:";
async function issueAntiReplayToken(req, res) {
const token = randomUUID();
await redis.setex(`${TOKEN_PREFIX}${token}`, TOKEN_TTL_SEC, "1");
res.json({ token, expiresIn: TOKEN_TTL_SEC });
}
function antiReplayToken(req, res, next) {
const isEarly = req.get("X-TLS-Early-Data") === "1";
const mutating = ["POST", "PUT", "PATCH", "DELETE"].includes(req.method);
if (!mutating) {
return next();
}
if (isEarly) {
res.set("Retry-After", "0");
return res.status(425).send("Too Early");
}
const token = req.get("X-Anti-Replay-Token");
if (!token) {
return res.status(428).json({ error: "Anti-Replay token required" });
}
const key = `${TOKEN_PREFIX}${token}`;
redis
.multi()
.get(key)
.del(key)
.exec()
.then((results) => {
const value = results[0][1];
if (!value) {
return res.status(409).json({ error: "Token already used or expired" });
}
next();
})
.catch((err) => {
req.log?.error(err);
res.status(503).json({ error: "Replay store unavailable" });
});
}
module.exports = { issueAntiReplayToken, antiReplayToken };
// routes.js — トークン発行と Idempotency-Key の併用
const { issueAntiReplayToken, antiReplayToken } = require("./middleware/antiReplayToken");
app.get("/api/anti-replay-token", issueAntiReplayToken);
app.post("/api/transfer", antiReplayToken, idempotencyMiddleware, transferHandler);
Anti-Replay トークンは「この HTTP リクエストを1回だけ実行する」ための短期 nonce だ。Idempotency-Key(べき等性設計)は「同じビジネス操作の再送なら同じ結果を返す」ための長めのキー。0-RTT 環境では 425 拒否 + Anti-Replay トークン + Idempotency-Key の三層が理想だが、API サーバーでは ssl_early_data off だけでも十分なことが多い。
トークン設計のチェックリスト
| 項目 | 推奨 |
|---|---|
| 発行経路 | 必ず 0-RTT 非使用の GET(1-RTT 完了後) |
| TTL | 30〜120秒。長すぎるとトークン窃取の窓が広がる |
| 消費 | GET + DEL を原子的に(Redis MULTI または Lua スクリプト) |
| スコープ | ユーザー単位・操作種別単位で namespace を分ける |
| 障害時 | Redis ダウン時は 503 で fail-closed(処理続行しない) |
| early data | POST 等は early ならトークン以前に 425 |
Anti-Replay(反リプレイ)パターン
0-RTTを部分的に残す場合、TLSの外側で同じリクエストの再実行を検出・拒否する仕組みが必要だ。
パターン1:HTTP 425 Too Early で非冪等メソッドを拒否
nginxの $ssl_early_data による メソッド制限は最初の防波堤になる。POST/PUT/PATCH/DELETEは425で落とし、クライアントにハンドシェイク完了後の再送を促す。
map $ssl_early_data $is_early {
default 0;
1 1;
}
map "$is_early:$request_method" $reject_early {
default 0;
"1:POST" 1;
"1:PUT" 1;
"1:PATCH" 1;
"1:DELETE" 1;
}
server {
ssl_early_data on;
if ($reject_early) {
return 425;
}
location / {
root /var/www/static;
add_header Cache-Control "public, max-age=3600";
}
}
ブラウザは425を受け取ると、通常は同じリクエストを0-RTTなしで再送する。Fetch APIやXHRの挙動は環境差があるため、自前クライアントではリトライ実装を確認すること。
パターン2:アプリケーション層のリプレイウィンドウ(サーバー側キャッシュ)
サーバー(または共有Redis)に ClientHello内の情報とリクエストのハッシュを短時間保存し、重複を拒否する方式。nginx単体では完結しにくく、アプリまたはOpenSSLカスタムフック、Envoyのようなプロキシで実装される。
概念コード(擬似):
# early data 受理時のみ実行
def accept_early_request(client_random: bytes, req_hash: str) -> bool:
key = f"early:{client_random.hex()}:{req_hash}"
# SET NX — 既にあればリプレイ
if not redis.set(key, "1", nx=True, ex=30):
raise TooEarlyError("replay detected")
return True
ウィンドウ長(例:30秒)は PSKの寿命・ロードバランサの粘着性とトレードオフになる。複数サーバー間ではRedisなど共有ストアが必須だ。
パターン3:Idempotency-Key によるべき等化
状態変更APIでは、0-RTTの有無に関わらず Idempotency-Key を必須にする。同じキーで再送されても1回分の副作用に抑えられる。0-RTTリプレイは「クライアントが意図せず同じリクエストを2回送った」ケースと同型に扱える。
POST /api/orders HTTP/1.1
Host: api.example.com
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{"sku":"ABC","qty":1}
パターン4:ワンタイムトークン(nonce)
フォーム送信やCSRF対策と組み合わせ、一度きり使えるnonceをearly dataリクエストに要求する。リプレイでは同じnonceが二度使えない。ただしGETにnonceを載せるとキャッシュが効かなくなるため、変更操作に限定する。
パターン5:ホスト・パス分離(インフラAnti-Replay)
最も単純で強力なのは 0-RTTを静的配信ホストだけに限定することだ。
| ホスト | ssl_early_data | 用途 |
|---|---|---|
static.example.com | on(GETのみ) | 画像・JS・CSS |
api.example.com | off | REST/GraphQL |
www.example.com | off | HTML(動的要素あり) |
攻撃面を「キャッシュ可能なGETだけ」に切り詰めると、アプリ層のリプレイ検出の負担が激減する。
0-RTT 環境におけるべき等性(Idempotency)要件
0-RTT を有効にする、または CDN 経由で early data が流通しうる環境では、API のべき等性設計 が 必須のコンプライアンス要件に近づく。通常の「タイムアウト再送」に加え、攻撃者またはネットワークが意図せず同一リクエストを複製する経路が増えるからだ。
エンドポイントごとの要件
| エンドポイント種別 | 0-RTT 受理 | Idempotency-Key | Anti-Replay トークン |
|---|---|---|---|
| 静的 GET(CDN) | 可 | 不要 | 不要 |
| 参照系 GET(DB読み取り) | 条件付き可 | 不要 | 不要(副作用なしを確認) |
| POST 注文・決済 | 不可(425/off) | 必須 | 推奨 |
| PUT 上書き更新 | 不可 | 推奨 | 推奨 |
| PATCH 部分更新 | 不可 | 必須(累積型更新は特に) | 推奨 |
| DELETE | 不可 | 任意(2回目404で足りる場合も) | 任意 |
| Webhook 受信 | N/A(サーバー発) | イベント ID で必須 | N/A |
実装ルール(0-RTT 対応 API の最低ライン)
- 状態変更はすべて Idempotency-Key ヘッダー必須 — キーは操作単位の UUID v4、24時間以上サーバー側に結果キャッシュ
- early data 由来の POST は 425 — nginx の
$ssl_early_dataまたは Node のX-TLS-Early-Dataで判定 - 外部決済 API(Stripe 等)へ同じ Idempotency-Key を転送 — べき等性設計ガイド の Stripe フローと同一キーに揃える
- GET で副作用を持たせない —
GET /export?run=1のようなトリガー型は POST に変更 - 監査ログに
early=1を記録 — インシデント時に 0-RTT 経路か判別できるようにする
// idempotencyMiddleware.js — 0-RTT 再送とリプレイの両方に耐える最小例
async function idempotencyMiddleware(req, res, next) {
const key = req.get("Idempotency-Key");
if (!key) {
return res.status(400).json({ error: "Idempotency-Key required" });
}
const cacheKey = `idempotency:${req.method}:${req.path}:${key}`;
const cached = await redis.get(cacheKey);
if (cached) {
const { status, body } = JSON.parse(cached);
return res.status(status).json(body);
}
const originalJson = res.json.bind(res);
res.json = (body) => {
redis.setex(
cacheKey,
86400,
JSON.stringify({ status: res.statusCode || 200, body })
);
return originalJson(body);
};
next();
}
同一server_nameで動いているルートをすべて列挙し、副作用のあるメソッドをマークする
ssl_early_data on のまま残すルートが GET/HEAD のみか確認する
POST等が混ざるなら server ブロックを分割するか ssl_early_data off に切り替える
残す場合は 425 拒否・Idempotency-Key・共有リプレイキャッシュのいずれかを実装する
ステージングで openssl s_client による再送テストを行い、ログで二重実行が起きないことを確認する
0-RTTを無効化すべきケース
次に当てはまるなら、性能より安全側を選ぶべきだ。
1. 認証・決済・状態変更APIが同一オリジン
ログイン、カート追加、送金、投票、Webhookトリガーなどが example.com 直下にある。ssl_early_data off が無難。TTFBの数msより、二重決済インシデントのコストの方が桁違いに大きい。
2. Anti-Replayをアプリ層まで実装する余力がない
425拒否だけでは「冪等なGETのリプレイ」は防げない。GETが副作用を持つ(例:GET /api/export?trigger=1 でレポート生成)なら、リプレイでコストや情報漏洩が起きうる。実装体制がなければ0-RTTごと切る。
3. マルチテナント・マルチリージョンで共有状態が複雑
リプレイウィンドウをRedisで持つ設計は、リージョン跨ぎ・フェイルオーバ時に抜け穴が出やすい。グローバル単一Redisに依存できないチームは、0-RTT無効の方が運用コストが低い。
4. 規制・監査で「再送耐性」の説明責任がある
金融・医療・公共系では、プロトコル仕様上のリプレイリスクをドキュメントで説明し、補償コントロールを示す必要がある。offにして論点を消すのは合理的な選択だ。
5. 0-RTTの効果が計測で小さい
Chrome DevToolsやRUMで、再訪問TTFBの改善が 5ms未満 など誤差範囲なら、リスクを取る意味が薄い。CRP最適化やCDNキャッシュの方が効くことが多い。
| 運用パターン | 0-RTT推奨 |
|---|---|
| 静的CDNのみ(GET/HEAD) | 有効化可。425・ホスト分離とセット |
| 一般ECサイト(HTML+API同居) | 無効化推奨 |
| モバイルAPI(POST多め) | 無効化必須に近い |
| 社内管理ツール | 無効化。傍受リスクより誤設定リスク |
| HTTP/3 + 高リピート率メディア | 静的ホストのみ有効化を検討 |
openssl s_client での検証手順
本番やステージングで0-RTTが本当に有効か、無効化設定が効いているかを OpenSSLの s_client で確認できる。テスト用に本番と別ホストを使い、負荷のかかるループは避けること。
前提:OpenSSL 1.1.1 以降
0-RTTには OpenSSL 1.1.1+ が必要。バージョン確認:
openssl version
# OpenSSL 3.x.x または 1.1.1 以上
手順1:フルハンドシェイクでセッションを保存
# セッションをファイルに保存(-sess_out)
openssl s_client \
-connect example.com:443 \
-servername example.com \
-tls1_3 \
-sess_out /tmp/example.com.session \
</dev/null
出力で次を確認する:
Protocol : TLSv1.3New, TLSv1.3, Cipher is ...- 接続末尾付近に NewSessionTicket 相当のメッセージ(OpenSSLの詳細出力
-traceや-msgで確認可能)
# より詳細なハンドシェイクログ
openssl s_client \
-connect example.com:443 \
-servername example.com \
-tls1_3 \
-msg \
-sess_out /tmp/example.com.session \
</dev/null 2>&1 | grep -iE "session|ticket|early"
手順2:0-RTT early data 付きで再接続
# 早期データ文字列を送って再開(-early_data は OpenSSL 1.1.1+)
printf 'GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n' | \
openssl s_client \
-connect example.com:443 \
-servername example.com \
-tls1_3 \
-sess_in /tmp/example.com.session \
-early_data \
</dev/null
ssl_early_data on のサーバーでは、ハンドシェイク完了前後にHTTPレスポンスの断片が見えることがある。off なら、early dataは無視され、通常の1-RTTフロー後に応答が返る。
手順3:HTTPレスポンスと 425 の確認
APIエンドポイントに対してPOSTのearly dataを試す場合(検証環境のみ):
printf 'POST /api/ping HTTP/1.1\r\nHost: api.example.com\r\nContent-Length: 2\r\n\r\n{}' | \
openssl s_client \
-connect api.example.com:443 \
-servername api.example.com \
-tls1_3 \
-sess_in /tmp/api.example.com.session \
-early_data \
</dev/null 2>&1 | head -40
Anti-Replay設定が効いていれば HTTP/1.1 425 Too Early が期待される。
手順4:curl との比較
curl 7.65.0+ でもTLS 1.3セッション再開は可能だが、early dataの細かい制御は openssl s_client の方が明確なことが多い。
# TLS 1.3 強制・再開の簡易確認
curl -sI --tlsv1.3 --tls-max 1.3 https://example.com/ -o /dev/null -w '%{http_version} %{time_appconnect}\n'
# 2回目(同一プロセスのセッションキャッシュは curl では環境依存)
curl -sI --tlsv1.3 --tls-max 1.3 https://example.com/ -o /dev/null -w '%{time_appconnect}\n'
本番の定常監視には、週次で openssl s_client をCIから叩き、$ssl_early_data がログに出ていないか、意図しない425/200を検知するのも手だ。
nginxアクセスログでの確認
log_format tls_debug '$remote_addr - $request '
'early=$ssl_early_data '
'proto=$ssl_protocol '
'cipher=$ssl_cipher';
access_log /var/log/nginx/tls_early.log tls_debug;
early=1 がPOSTに付いていたら設定ミス。静的GETだけに限定できているか定期レビューする。
HSTS・HTTPS強制との関係
0-RTTは TLS 1.3の上でしか意味を持たない。HTTP→HTTPSの恒久リダイレクトと HSTS により、平文でのearly data議論は通常発生しない。
HSTS Preload まで進めているサイトは、初回からHTTPSのみとなり、SSLストリッピング後の0-RTT悪用リスクも下がる。ただしHSTSはリプレイ攻撃そのものを防がない。Preloadは「HTTPSで接続させる」仕組みであり、0-RTTの受け入れ可否とは独立だ。
推奨の組み合わせ:
- HSTS Preload手順に沿ってHTTPSを強制
- APIホストでは
ssl_early_data off - 静的ホストのみ0-RTTを検討し、GET限定と425を設定
- セキュリティヘッダー一覧で
Strict-Transport-Securityと併用
クライアント・ブラウザの挙動
主要ブラウザは、仕様に従い 安全でないと判断したリクエストを0-RTTに載せない 実装が進んでいる。ただし「ブラウザが載せない」と「サーバーが受理しない」は別問題だ。
| クライアント | 0-RTTの傾向 |
|---|---|
| Chrome / Edge | 再訪問のGET等で0-RTTを試行。サーバーが拒否すれば通常接続にフォールバック |
| Firefox | 同様。プライバシー設定や企業ポリシーで差異あり |
| Safari | iOS/macOSでTLS 1.3再開。挙動はバージョン依存 |
| curl / 自前SDK | 明示的にearly dataを送れる。バックエンド間通信の設定ミスに注意 |
自前のモバイルアプリやマイクロサービス間TLSで SSL_write_early_data を使う場合は、アプリケーションプロトコル側のリプレイ対策がより重要になる。
ブラウザが0-RTTに載せにくいリクエストの例
仕様と実装の両方から、次のようなリクエストはearly dataに載りにくい(ただしサーバー側でも拒否すべき)。
| リクエスト種別 | 0-RTTに載せるべきか |
|---|---|
静的アセットの GET(キャッシュ可) | 載せてもリスク低(リプレイされても同じ200) |
GET だがサーバー状態を変える(在庫引当等) | 載せない |
POST / PUT / PATCH / DELETE | 載せない・受理しない |
| Cookie付きでセッション確立直後のナビゲーション | 実装依存。サーバーは ssl_early_data off が無難 |
| 認証ヘッダー付きAPIコール | 載せない |
「ブラウザが送らないから安全」は成立しない。curl・モバイルSDK・ボットは制限なくearly dataを送れる。
関連RFCと仕様上の位置づけ
0-RTTを議論するとき、次の文書を参照すると設計判断がブレにくい。
| RFC / 文書 | 内容 |
|---|---|
| RFC 8446 | TLS 1.3。0-RTTと「リプレイ可能であること」を定義 |
| RFC 8470 | HTTP 425 Too Early。early dataの拒否方法 |
| RFC 9000 / 9001 | QUICとTLS 1.3の統合。HTTP/3の0-RTT |
| RFC 9114 | HTTP/3。QUIC上のセマンティクス |
RFC 8446 Section 8 は、アプリケーションが max_early_data_size の範囲で受け取ったデータについて、少なくとも1回のリプレイを想定した設計を要求する。これは「将来の拡張で完全防止する」ではなく、今の時点での公式な割り切りだ。
HTTPレイヤでは RFC 8470 により、サーバーは early data を処理したくない場合に 425 Too Early を返し、クライアントに再試行を促せる。nginxの return 425; はこの標準に沿った応答だ。
ロードバランサ・リバースプロキシでの注意
nginx以外のスタックでも、0-RTTは終端ポイントの設定で決まる。複数層でTLSを終端していると、クライアント↔LBでは0-RTTが効き、LB↔オリジンでは別セッションになる。
AWS Application Load Balancer(ALB)
ALBはクライアントとのTLS 1.3を終端する。セキュリティポリシー ELBSecurityPolicy-TLS13-1-2-... 系ではTLS 1.3が有効だが、0-RTTの扱いはポリシーとリージョンで変わるため、AWSドキュメントで当該ポリシーの 0-RTT 記載を確認すること。オリジンへは通常、新しいTLSまたはHTTP接続で転送されるため、リプレイリスクはALB終端側で評価する。
Envoy
Envoyでは allow_early_data などリスナー・クラスタ設定で0-RTTを制御する。Service Mesh構成では、サイドカー間のmTLSでearly dataが有効になるケースもあり、East-Westトラフィックでもリプレイ設計が必要になる。
# 概念例: early data を拒否する方向
transport_socket:
name: envoy.transport_sockets.tls
typed_config:
"@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.DownstreamTlsContext
common_tls_context:
tls_params:
tls_minimum_protocol_version: TLSv1_3
# バージョンにより allow_early_data 等のフィールド名を要確認
二重TLS終端(CDN → LB → nginx)
Browser ──0-RTT?──► CDN Edge ──通常TLS──► ALB ──HTTP──► nginx
クライアントが0-RTTで送ったリクエストはCDNで一度復号される。CDNがオリジンへ転送するときに新規HTTPリクエストになるため、オリジンnginxの $ssl_early_data は 0(空) になりがちだ。つまり Anti-ReplayはCDN層かアプリ層で設計しないと穴が開く。CDNの0-RTTをONにしたなら、CDNのログとWAFルールもセットで見る。
脅威モデリングの簡易フロー
0-RTTを有効にするか迷ったときの判断木だ。
early data を有効にしたい(性能)
│
▼
副作用のあるAPIは同一ホストか?
┌────┴────┐
YES NO
│ │
▼ ▼
off GET/HEAD のみか?
推奨 ┌────┴────┐
NO YES
│ │
▼ ▼
off Anti-Replay実装済み?
┌────┴────┐
NO YES
│ │
▼ ▼
off on + 監視・425
多くのWebアプリは左の枝(off)で終わる。静的アセット専用ドメインだけ右側に進む。
よくある誤解
| 誤解 | 実際 |
|---|---|
| 「TLS 1.3だからリプレイ安全」 | 0-RTTは仕様上リプレイ可能 |
| 「HTTPSだから傍受されない」 | エンドユーザー端末・不正AP・内部侵害者は傍受しうる |
| 「ブラウザが守ってくれる」 | サーバー設定と非ブラウザクライアントがボトルネック |
| 「425を返せば十分」 | 冪等なGETのリプレイや、複数LB間の状態共有は別問題 |
| 「HSTS Preloadで0-RTTも安全」 | HSTS PreloadはHTTPS強制であり、リプレイ対策ではない |
トラブルシューティング
425 Too Early が大量に出る
原因: ssl_early_data on なのに、クライアントがPOSTをearly dataで送っている。SPAの初回ナビゲーションとAPIコールが同時に走ると発生しうる。
対処:
- API用ホストで
ssl_early_data off - またはクライアントが425を受けたら自動再送する(Fetchは一部ケースで対応)
0-RTTを有効にしたのに効果がない
原因: セッションチケット期限切れ、中間プロキシがTLSを再終端、CDNとオリジンで設定不一致。
確認:
openssl s_client -sess_out/-sess_inテスト- CDNの「0-RTT」トグルとオリジンnginxの
ssl_early_dataの両方
early data 有効時に断続的な接続失敗
原因: ssl_buffer_size が極端に小さい、early dataサイズ超過、HTTP/2とHTTP/3の設定競合。
対処: nginxエラーログの SSL_read_early_data 関連を確認。まず ssl_early_data off で切り分け。
チェックリスト(本番投入前)
openssl s_client でセッション再開と early data の受理有無を記録する
POST/PUT/PATCH/DELETE が early=1 のログに出ないことを確認する
決済・認証・Webhookのエンドポイントが ssl_early_data off のホストにある
0-RTT有効時は Idempotency-Key またはリプレイキャッシュが状態変更APIを保護している
CDN・ロードバランサ・nginxの三層で設定が矛盾していない
HSTS(必要なら Preload)でHTTPS強制が完了している
0-RTT off 時のTTFB悪化をRUMで許容範囲と確認した
関連記事
この記事は セキュリティ テーマの一環です。あわせて読むと理解が深まる関連記事をまとめました。
| トピック | 記事 |
|---|---|
| XSS攻撃と防御の完全ガイド | XSS攻撃と防御の完全ガイド|反射型・蓄積型・DOM型の対策 |
| CSRF対策の実装ガイド | CSRF対策の実装ガイド|SameSite・Synchronizer Token・Laravel/Expressコード |
| Double Submit Cookie完全実装 | Double Submit Cookie完全実装|Synchronizer Token比較・SPA+JWT・サブドメイン攻撃 |
| Content Security Policy (CSP) 設定ガイド | Content Security Policy (CSP) 設定ガイド|XSS・クリックジャッキング対策 |
| CSP Report-Only 段階導入ガイド | CSP Report-Only 段階導入ガイド|本番を壊さずポリシーを検証する |
| セキュアなCookie設定ガイド | セキュアなCookie設定ガイド|HttpOnly・Secure・SameSiteの使い分けとCSRF対策 |
| CORSエラーの対処法 | CORSエラーの対処法|「Access-Control-Allow-Origin」で解決する |
| CORSプリフライトリクエスト | CORSプリフライトリクエスト|OPTIONSが飛ぶ条件と正しい応答 |
| ローカル開発でCORSエラーが出る原因と3つの回避策 | ローカル開発でCORSエラーが出る原因と3つの回避策 |
| OAuth2 PKCE SPA実装ガイド | OAuth2 PKCE SPA実装ガイド|Authorization Code + S256 + BFFパターン |
| Passkeys・WebAuthn実装の基礎 | Passkeys・WebAuthn実装の基礎|FIDO2・Resident Key・@simplewebauthn/server |
| JWTのデバッグ方法 | JWTのデバッグ方法|トークンの中身を安全に確認する |
まとめ
TLS 1.3の 0-RTT(early data) は、再訪問レイテンシを削る強力な機能だが、リプレイ攻撃リスクをプロトコルが明示的に受け入れている点を理解したうえで設計する必要がある。
| やること | 推奨 |
|---|---|
| 迷ったら | ssl_early_data off; |
| 静的配信のみ | 別ホストで on + GET限定 + 425 |
| 状態変更API | 0-RTT禁止 + べき等性 + Anti-Replay トークン |
| Node.js | rejectEarlyData ミドルウェア + maxEarlyDataSize: 0 |
| HTTPS強制 | HSTS Preload と併用 |
| 検証 | openssl s_client -sess_in -early_data |
TLS 0-RTT リスクは「early data リプレイ攻撃」として長尾キーワードでも検索されるが、実務の答えはシンプルだ。副作用のあるリクエストをearly dataに載せない。載せるならアプリでAnti-Replayを実装する。性能数msのためにセキュリティインシデントを引き受ける価値があるか、計測と脅威モデリングのあとで決めればよい。