0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ローカルでは通る HTTP ヘッダが、本番の CDN / WAF で落ちることがある

0
Last updated at Posted at 2026-05-21

ローカルで普通に動いていた API が、本番環境へ出した途端 400 Bad Request を返します。

バックエンドのログを見ると、そもそもアプリケーションまでリクエストが届いていない。
CDN や WAF のログを見ると、どうやらヘッダが不正だと判定されている。

こういうとき、原因がアプリケーションのロジックではなく、HTTP ヘッダの構文にあることがあります。

この記事では、特に見落としやすい 2 つの例を扱います。

  • If-Match ヘッダの値は "1" のように引用符で囲む必要がある
  • Content-Type の JSON 判定では application/problem+json のような +json も許容する必要がある

どちらも、普段のフレームワーク上では雑に扱っても動いてしまうことがあります。
しかし、CDN、WAF、リバースプロキシ、ゲートウェイを通すと、仕様に近いところで急に表面化します。

まず、楽観ロックのよくある実装

たとえば、更新 API で楽観ロックを使うとします。サーバー側のレコードに version を持たせておき、クライアントが直前に取得した version と一致するときだけ更新を受け付ける方式です。

HTTP では、この用途に If-Match ヘッダを使えます。

// version はサーバから取得した値
await fetch(url, {
  method: 'PUT',
  headers: {
    'If-Match': String(version),
  },
  body: JSON.stringify(input),
});

一見、これで動きそうです。

version が 1 なら、送られるヘッダは次のようになります。

If-Match: 1

バックエンド側でこの値を取り出して、DB の version と比較するだけなら、実際に動くこともあります。

const ifMatch = req.headers.get('if-match');
// ifMatch === '1' として扱う

しかし HTTP の仕様上、この書き方は正しくありません。

If-Match: 1 は仕様上は不正

If-Match に入れる値は、HTTP の entity-tag、つまり ETag の形式である必要があります。ETag は単なる生文字列ではなく、引用符で囲まれた文字列です。

つまり、次のように送る必要があります。

If-Match: "1"

1 ではなく "1" です。

JavaScript の文字列として書くなら、こうなります。

await fetch(url, {
  method: 'PUT',
  headers: {
    'If-Match': `"${version}"`,
  },
  body: JSON.stringify(input),
});

ここで少しややこしいのは、HTTP ヘッダの値としての引用符と、JavaScript の文字列リテラルとしての引用符が別物だという点です。

'If-Match': String(version)
// 送信される値: 1
'If-Match': `"${version}"`
// 送信される値: "1"

HTTP 的に必要なのは、後者です。

なぜローカルでは動いてしまうのか

多くのバックエンドフレームワークでは、ヘッダ値は単なる文字列として取得できます。

そのため、アプリケーション側で次のような実装をしていると、If-Match: 1 でも動いてしまいます。

const ifMatch = req.headers.get('if-match');

if (ifMatch !== String(currentVersion)) {
  return new Response('Precondition Failed', { status: 412 });
}

このコードでは、HTTP の entity-tag として正しいかどうかを見ていません。
ただの文字列比較をしているだけです。

だから、ローカルでは通ります。

しかし、本番ではリクエストが次のような経路を通ることがあります。

Browser
  ↓
CDN
  ↓
WAF
  ↓
Reverse Proxy
  ↓
Application

この途中にある CDN や WAF が、HTTP ヘッダをより厳格に検査していると、If-Match: 1 は不正な値として落とされます。

その場合、アプリケーションにはリクエストが届きません。
バックエンドのログを見ても何も出てこないため、原因が分かりにくくなります。

RFC 上はどう定義されているのか

RFC 9110 では、entity-tag は次のような構文で定義されています。

entity-tag = [ weak ] opaque-tag
weak       = %s"W/"
opaque-tag = DQUOTE *etagc DQUOTE

細かい ABNF をすべて覚える必要はありません。
ここで重要なのは、opaque-tag が DQUOTE で囲まれていることです。

つまり、ETag は次のような形式になります。

ETag: "abc"
ETag: "1"
ETag: W/"abc"

W/ が付いているものは weak ETag です。
一方で、次のような値は entity-tag として正しくありません。

If-Match: 1
If-Match: abc

If-Match ヘッダは、* または entity-tag のリストを受け取ります。

If-Match = "*" / 1#entity-tag

そのため、正しい例は次のようになります。

If-Match: "1"
If-Match: "abc"
If-Match: W/"abc"
If-Match: *

楽観ロックで version 番号を使う場合でも、HTTP ヘッダ上では "1" のように entity-tag として送る必要があります。

サーバー側でも正規化しておく

クライアント側で正しく送るのが基本ですが、サーバー側でも If-Match を直接比較するより、最初にパースしておく方が安全です。

たとえば、次のように entity-tag として解釈する関数を用意します。

function parseEntityTag(raw: string | null): string | null {
  if (raw == null) return null;

  const trimmed = raw.trim();

  if (trimmed === '*') {
    return '*';
  }

  const m = /^(?:W\/)?"([^"]*)"$/.exec(trimmed);
  return m ? m[1] : null;
}

この関数では、次の値を受け付けます。

If-Match: "1"
If-Match: W/"abc"
If-Match: *

一方で、次のような値は null になります。

If-Match: 1
If-Match: abc

bare な値を後方互換のために受け付けるかどうかは設計判断です。
ただ、少なくとも「仕様上の正しい値」と「たまたま通っていた値」を分けて扱えるようになります。

Content-Type の JSON 判定という類例

ここまでの話は If-Match の引用符でした。

もう 1 つ、同じ種類の注意点があります。
それが、レスポンスの Content-Type を見て JSON かどうか判定する処理です。

たとえば、fetch ラッパで次のようなコードを書くことがあります。

const ct = res.headers.get('content-type') ?? '';

if (!ct.toLowerCase().includes('application/json')) {
  throw new Error(`Unexpected content-type: ${ct}`);
}

return await res.json();

このチェック自体に問題はありません。

ゲートウェイが 502 Bad Gateway を返したときに HTML のエラーページが返ってくることがあります。
そういうレスポンスを JSON として res.json() しようとすると、別のエラーになります。

そのため、Content-Type を確認すること自体は妥当です。

ただし、application/json だけを JSON とみなすと、正当な JSON 系のメディアタイプまで弾いてしまいます。

application/problem+json も JSON として扱える

代表例が application/problem+json です。

これは、HTTP API のエラー詳細を表現するためのメディアタイプです。
RFC 9457 で定義されており、ボディは JSON です。

たとえば、次のようなレスポンスです。

HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
{
  "type": "https://example.com/problems/invalid-request",
  "title": "Invalid request",
  "status": 400,
  "detail": "Name is required"
}

これは application/json ではありません。
しかし、JSON としてパースしてよいレスポンスです。

なぜなら、末尾に +json が付いているからです。

この +json は、構造化シンタックスサフィックスと呼ばれるものです。
ざっくり言うと、「このメディアタイプの中身は JSON の構造を持っている」という印です。

そのため、次のようなメディアタイプも JSON として扱えます。

application/problem+json
application/vnd.example+json
application/ld+json

つまり、JSON 判定では application/json だけでなく、application/*+json も受け入れる必要があります。

+json を許容する JSON 判定

実装するなら、たとえば次のようにします。

function isJsonContentType(contentType: string | null): boolean {
  if (contentType == null) return false;

  return /^application\/(?:[\w.+-]+\+)?json\b/i.test(contentType);
}

この関数は、次のような値を JSON として扱います。

application/json
application/json; charset=utf-8
application/problem+json
application/problem+json; charset=utf-8
application/vnd.example+json
application/ld+json

一方で、次のような値は JSON として扱いません。

text/html
text/plain
application/xml
application/jsonml

includes('application/json') だけを見る実装よりも、+json サフィックスを扱える分だけ安全です。

2 つの話に共通していること

If-Match の引用符と、Content-Type の +json は別の話です。ただし実務上の注意点としては似ています。どちらも、アプリケーションの中だけを見ていると雑に扱っても動いてしまいます。If-Match は、バックエンドでただの文字列として比較すれば、1 でも "1" でも動くことがあります。Content-Type は、普段の成功レスポンスだけを見ていれば、application/json だけで足りるように見えます。しかし、実際の HTTP リクエスト / レスポンスは、アプリケーションだけで完結しません。

CDN、WAF、リバースプロキシ、API Gateway、ブラウザー、クライアントライブラリなど、さまざまな層を通ります。
そのどこかで仕様に近い解釈が入ると、ローカルでは見えなかった差が表に出ます。

だから、HTTP ヘッダを扱うところでは、次のようにしておくと事故が減ります。

  • 送る側は、仕様に合った形式で送る
  • 受ける側は、最初にパースまたは正規化する
  • アプリケーション内では、正規化後の値だけを使う
  • Content-Type のような判定では、完全一致ではなくメディアタイプのルールに沿って見る

まとめ

If-Match で楽観ロック用の version を送る場合、値は "1" のように引用符で囲んだ entity-tag として送る必要があります。

If-Match: "1"

次のような bare な値は、バックエンドでは動くことがあっても、仕様上は正しくありません。

If-Match: 1

また、レスポンスを JSON として扱うかどうかを Content-Type で判定する場合を考えます。ここでは application/json だけでなく、application/problem+json のような +json サフィックス付きメディアタイプも許容する必要があります。

Content-Type: application/problem+json

どちらも小さな話に見えますが、本番環境で CDN / WAF / ゲートウェイを経由すると急に問題化します。

ローカルでは通る。
アプリケーションログには出ない。
でも本番では落ちる。

そういう種類の不具合を避けるために、HTTP ヘッダは「ただの文字列」ではなく、仕様上の構文を持つ値として扱っておくと安心です。

参考資料

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?