Day 062 — 2026-10-01 | ミドルウェア設計パターン: リクエスト本文の上限をどこで・何バイトで切るか——Content-Length の先読み・MaxBytesReader・gzip 展開後の 3 点を 11 本のテストで固める

2026-10-01(バックフィル) 🟡 上級者 / Phase 3 実装 ミドルウェア設計パターン

フェーズ: 上級者 (Phase 3) | 問題タイプ: 実装 | テーマ: ミドルウェア設計パターン
10/1 のバックフィル分(実際の作成日は 2026-10-03。番号は state の「次の番号」を使った)。Day 059 は「回数」の上限(レート制限)、Day 061 は「時間」の上限(タイムアウト)だった。今日は残る軸の 「大きさ」の上限。

📚 背景知識

Go の net/http は、ヘッダの大きさには既定の上限がある(DefaultMaxHeaderBytes = 1 << 20、server.go 939 行目)が、本文には上限がありません。io.ReadAll(r.Body) と書いた瞬間、クライアントが 10 GB 送ればサーバはメモリを使い切るまで読みます。上限は自分で入れる前提で、そのための部品が http.MaxBytesReader です。

go doc net/http.MaxBytesReader(Go 1.27.1、request.go 1199 行目)
  "MaxBytesReader prevents clients from accidentally or maliciously sending a large request and wasting server resources.
   If possible, it tells the ResponseWriter to close the connection after the limit has been reached."

ここでの設計判断は 3 つあります。

判断選択肢今日の結論
① いつ断るか本文を読む前(Content-Length を見る)/読みながら(MaxBytesReader)両方。前者は無料で早い。ただし Transfer-Encoding: chunked には長さが無いので後者が本命
② 超えたとき何を返すか黙って切る(io.LimitReader)/エラーにする(MaxBytesReader)エラー(413)。黙って切ると壊れたデータを正常として処理する(R8 で実測)
③ どの大きさを測るか受信したバイト数/展開した後のバイト数展開後。圧縮は 1000 倍以上に膨らむ(R7 の爆弾は 10 MiB が 10,221 バイト、実測)

Go の設計思想(小さい部品・明示的なエラー)では、「上限」は Server の設定項目ではなくミドルウェア+ハンドラ側のエラー処理の組になります。MaxBytesReader は読み込み時に *http.MaxBytesError を返すだけで、ステータスコードを決めるのはハンドラです。この分担を忘れると R8 の罠にはまります。

世界の実物との位置づけ(参照日 2026-10-03)

実物今日との関係
Go 標準 net/http(Go 1.27.1 の手元ソースを読んだ)MaxBytesReader(request.go 1209 行目)は w が内部の *response のときだけ requestTooLarge() を呼び、closeAfterReply = true と Connection: close を設定する(server.go 566 行目)。型アサーションが非公開メソッドに対して行われるので、ResponseWriter を包んだ値を渡すと合図が届かない(R11 で実測)
nginx の client_max_body_size(既定 1m、超過は 413)前段のプロキシで切るのは正攻法だが、アプリが直接叩かれる経路(社内・テスト・別 LB)では効かない。今日は前段の設定を読んでも実行してもいない(記憶による記述。未確認)

今日の自作が扱う中核概念: 上限の 3 点(先読み・読みながら・展開後)と、それを無効にする 2 つの落とし穴(エラーを捨てるハンドラ、ResponseWriter のラップ)。扱わないものは末尾の「扱わなかった範囲」に理由つきで書きました。

📝 問題

課題

次の要件を満たすように、limit.go の 2 関数(BodyLimit と Gunzip)を実装してください。main.go(ルートごとの組み立て)と main_test.go(11 本)は変えません。スタブ(素通しの 2 関数)のまま go test を回すと、11 本中 10 本が落ちます(実測。通るのは R3「上限内は素通し」だけ)。

ルート事情組み立て(main.go)
POST /api/jsonJSON API。1 KiB までBodyLimit(1<<10)
POST /uploadアップロード。1 MiB までBodyLimit(1<<20)
POST /secure認証つき。1 KiB までauth の内側に BodyLimit
POST /gzgzip 本文を受ける。展開後 64 KiB までGunzip(BodyLimit(64<<10)(…))
POST /sloppy読み込みエラーを捨てる悪いハンドラ(罠の再現)BodyLimit(1<<10)
POST /wrappedResponseWriter を包むミドルウェアの内側に BodyLimitwrap(BodyLimit(…))

先に予想を書く(自己評価欄へ)

  • 問A: Content-Length: 2048 で 1 KiB 上限の API に 2 KiB を送ると、ハンドラは呼ばれるか。chunked で送った場合は。
  • 問B: 上限ちょうど(1024 バイト)と 1 バイト超(1025)で挙動はどう変わるか。
  • 問C: io.LimitReader で代用すると、1025 バイト以上を送ったクライアントは何を見るか。
  • 問D: gzip の本文に「圧縮後の Content-Length」で上限判定すると、どんな正しい要求が落ちるか。逆にどんな攻撃が通るか。

準備

mkdir day062 && cd day062
go mod init day062                          # モジュールを作る
# main.go・limit.go(まずスタブ)・main_test.go を置く
go test -count=1 -timeout 60s ./...         # スタブでは 10 本落ちる
go test -race -count=1 -timeout 60s ./...   # 実装後は -race でも通ること

ファイル 1: main.go(変えない)

package main

import (
	"encoding/json"
	"errors"
	"io"
	"net/http"
	"sync/atomic"
)

// 呼ばれた回数と、本文を何バイト読んだかを記録する(テストが観測する)。
type Stats struct {
	Calls     atomic.Int32
	BytesRead atomic.Int64
}

// readAll は本文を全部読む。上限超過なら 413、他のエラーは 400 を返す。
// ミドルウェアではなくハンドラ側の約束事: 読み込みエラーを必ず見る。
func readAll(w http.ResponseWriter, r *http.Request, s *Stats) ([]byte, bool) {
	b, err := io.ReadAll(r.Body)
	s.BytesRead.Add(int64(len(b)))
	if err != nil {
		var mbe *http.MaxBytesError
		if errors.As(err, &mbe) {
			http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
		} else {
			http.Error(w, "bad request", http.StatusBadRequest)
		}
		return nil, false
	}
	return b, true
}

func echoLen(s *Stats) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		s.Calls.Add(1)
		b, ok := readAll(w, r, s)
		if !ok {
			return
		}
		w.WriteHeader(http.StatusOK)
		_ = json.NewEncoder(w).Encode(map[string]int{"len": len(b)})
	})
}

// sloppy は読み込みエラーを無視して 200 を返す悪いハンドラ(R8 用)。
func sloppy(s *Stats) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		s.Calls.Add(1)
		b, _ := io.ReadAll(r.Body) // エラーを捨てる
		s.BytesRead.Add(int64(len(b)))
		w.WriteHeader(http.StatusOK)
		_ = json.NewEncoder(w).Encode(map[string]int{"len": len(b)})
	})
}

func auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Authorization") != "Bearer ok" {
			http.Error(w, "unauthorized", http.StatusUnauthorized)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// wrap は ResponseWriter を包むだけのミドルウェア(アクセスログ等の典型)。
type wrapped struct{ http.ResponseWriter }

func wrap(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		next.ServeHTTP(&wrapped{w}, r)
	})
}

func newMux(s *Stats) http.Handler {
	mux := http.NewServeMux()
	mux.Handle("POST /api/json", BodyLimit(1<<10)(echoLen(s)))     // 1 KiB
	mux.Handle("POST /upload", BodyLimit(1<<20)(echoLen(s)))       // 1 MiB
	mux.Handle("POST /sloppy", BodyLimit(1<<10)(sloppy(s)))        // R8
	mux.Handle("POST /secure", auth(BodyLimit(1<<10)(echoLen(s)))) // R6: 認証が外側
	mux.Handle("POST /gz", Gunzip(BodyLimit(64<<10)(echoLen(s))))  // R7: 展開後に 64 KiB
	mux.Handle("POST /wrapped", wrap(BodyLimit(1<<10)(sloppy(s)))) // R11: 包んだ writer を MaxBytesReader に渡す
	return mux
}

func main() {
	var s Stats
	_ = http.ListenAndServe("127.0.0.1:8080", newMux(&s))
}

ファイル 2: limit.go(ここを実装する。まずスタブ)

package main

import (
	"compress/gzip"
	"net/http"
)

var _ = gzip.NewReader // 実装で使う。未使用 import エラーを避けるための一行(実装したら消す)

// BodyLimit は本文の上限 max バイトを守るミドルウェア。
func BodyLimit(max int64) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler { return next }
}

// Gunzip は Content-Encoding: gzip の本文を展開して next に渡す。
// 展開後のサイズは内側の BodyLimit が見る。不正な gzip は 400。
func Gunzip(next http.Handler) http.Handler { return next }

ファイル 3: main_test.go(変えない)

package main

import (
	"bytes"
	"compress/gzip"
	"encoding/json"
	"io"
	"math/rand"
	"net/http"
	"net/http/httptest"
	"strings"
	"testing"
)

func setup(t *testing.T) (*httptest.Server, *Stats) {
	t.Helper()
	s := &Stats{}
	srv := httptest.NewServer(newMux(s))
	t.Cleanup(srv.Close)
	return srv, s
}

// post は本文を送る。chunked=true なら Content-Length を付けずに送る(Transfer-Encoding: chunked)。
func post(t *testing.T, url string, body []byte, chunked bool, hdr map[string]string) *http.Response {
	t.Helper()
	var rd io.Reader = bytes.NewReader(body)
	if chunked {
		rd = io.MultiReader(bytes.NewReader(body)) // 型を隠すと ContentLength は不明(-1 扱い)になる
	}
	req, err := http.NewRequest("POST", url, rd)
	if err != nil {
		t.Fatal(err)
	}
	for k, v := range hdr {
		req.Header.Set(k, v)
	}
	resp, err := http.DefaultClient.Do(req)
	if err != nil {
		t.Fatalf("リクエスト失敗: %v", err)
	}
	t.Cleanup(func() { resp.Body.Close() })
	return resp
}

func gz(t *testing.T, b []byte) []byte {
	t.Helper()
	var buf bytes.Buffer
	w := gzip.NewWriter(&buf)
	w.Write(b)
	w.Close()
	return buf.Bytes()
}

func gotLen(t *testing.T, r *http.Response) int {
	t.Helper()
	var m map[string]int
	if err := json.NewDecoder(r.Body).Decode(&m); err != nil {
		t.Fatalf("本文が JSON でない: %v", err)
	}
	return m["len"]
}

// R1: Content-Length が上限超なら、本文を 1 バイトも読まずに 413。ハンドラは呼ばれない。
func TestR1_ContentLengthFastReject(t *testing.T) {
	srv, s := setup(t)
	resp := post(t, srv.URL+"/api/json", make([]byte, 2048), false, nil)
	if resp.StatusCode != 413 {
		t.Fatalf("status=%d, want 413", resp.StatusCode)
	}
	if s.Calls.Load() != 0 || s.BytesRead.Load() != 0 {
		t.Fatalf("ハンドラが動いた: calls=%d read=%d", s.Calls.Load(), s.BytesRead.Load())
	}
}

// R2: chunked(長さ不明)は先読みできない。ハンドラは呼ばれ、読み込み中に 413 になる。
func TestR2_ChunkedStopsMidRead(t *testing.T) {
	srv, s := setup(t)
	resp := post(t, srv.URL+"/api/json", make([]byte, 2048), true, nil)
	if resp.StatusCode != 413 {
		t.Fatalf("status=%d, want 413", resp.StatusCode)
	}
	if s.Calls.Load() != 1 {
		t.Fatalf("chunked ではハンドラが 1 回呼ばれるはず: %d", s.Calls.Load())
	}
	if got := s.BytesRead.Load(); got > 1<<10 {
		t.Fatalf("上限 1024 を超えて読んだ: %d", got)
	}
}

// R3: 上限内は素通し。
func TestR3_UnderLimitPasses(t *testing.T) {
	srv, _ := setup(t)
	resp := post(t, srv.URL+"/api/json", make([]byte, 100), false, nil)
	if resp.StatusCode != 200 || gotLen(t, resp) != 100 {
		t.Fatalf("status=%d", resp.StatusCode)
	}
}

// R4: 境界。ちょうど 1024 は通り、1025 は 413(Content-Length 付きでも chunked でも同じ)。
func TestR4_Boundary(t *testing.T) {
	srv, _ := setup(t)
	for _, chunked := range []bool{false, true} {
		if r := post(t, srv.URL+"/api/json", make([]byte, 1024), chunked, nil); r.StatusCode != 200 {
			t.Errorf("chunked=%v 1024 バイト: status=%d, want 200", chunked, r.StatusCode)
		}
		if r := post(t, srv.URL+"/api/json", make([]byte, 1025), chunked, nil); r.StatusCode != 413 {
			t.Errorf("chunked=%v 1025 バイト: status=%d, want 413", chunked, r.StatusCode)
		}
	}
}

// R5: 上限はルートごと。同じ 2 KiB が /upload(1 MiB)は通り、/api/json(1 KiB)は断られる。
func TestR5_PerRoute(t *testing.T) {
	srv, _ := setup(t)
	if r := post(t, srv.URL+"/upload", make([]byte, 2048), false, nil); r.StatusCode != 200 {
		t.Errorf("/upload status=%d", r.StatusCode)
	}
	if r := post(t, srv.URL+"/api/json", make([]byte, 2048), false, nil); r.StatusCode != 413 {
		t.Errorf("/api/json status=%d", r.StatusCode)
	}
}

// R6: 認証が外側。未認証なら本文の大きさに関係なく 401(413 ではない)。
func TestR6_AuthBeforeLimit(t *testing.T) {
	srv, s := setup(t)
	if r := post(t, srv.URL+"/secure", make([]byte, 2048), false, nil); r.StatusCode != 401 {
		t.Errorf("未認証: status=%d, want 401", r.StatusCode)
	}
	if r := post(t, srv.URL+"/secure", make([]byte, 2048), false, map[string]string{"Authorization": "Bearer ok"}); r.StatusCode != 413 {
		t.Errorf("認証済み・超過: status=%d, want 413", r.StatusCode)
	}
	if s.Calls.Load() != 0 {
		t.Errorf("ハンドラが呼ばれた: %d", s.Calls.Load())
	}
}

// R7: gzip 爆弾。圧縮後は約 10 KiB だが展開すると 10 MiB。上限は「展開後」に効かせる。
func TestR7_GzipBomb(t *testing.T) {
	srv, s := setup(t)
	hdr := map[string]string{"Content-Encoding": "gzip"}

	bomb := gz(t, make([]byte, 10<<20))
	if len(bomb) > 64<<10 {
		t.Fatalf("爆弾が大きすぎて前提が崩れた: %d", len(bomb))
	}
	r := post(t, srv.URL+"/gz", bomb, false, hdr)
	if r.StatusCode != 413 {
		t.Fatalf("爆弾: status=%d, want 413", r.StatusCode)
	}
	if got := s.BytesRead.Load(); got > 64<<10 {
		t.Errorf("展開後 64 KiB を超えて読んだ: %d", got)
	}

	ok := post(t, srv.URL+"/gz", gz(t, bytes.Repeat([]byte("a"), 5000)), false, hdr)
	if ok.StatusCode != 200 || gotLen(t, ok) != 5000 {
		t.Errorf("正常な gzip が通らない: status=%d", ok.StatusCode)
	}

	bad := post(t, srv.URL+"/gz", []byte("not gzip"), false, hdr)
	if bad.StatusCode != 400 {
		t.Errorf("壊れた gzip: status=%d, want 400", bad.StatusCode)
	}
}

// R8: 罠。読み込みエラーを捨てるハンドラは、切り詰められた本文に 200 を返す。
// ミドルウェアでは直せない(MaxBytesReader は接続を閉じさせるが、ステータスは決めない)。
func TestR8_SloppyHandlerSeesTruncation(t *testing.T) {
	srv, _ := setup(t)
	resp := post(t, srv.URL+"/sloppy", make([]byte, 4096), true, nil)
	if resp.StatusCode != 200 {
		t.Fatalf("status=%d, want 200(切り詰めに気づかない)", resp.StatusCode)
	}
	if n := gotLen(t, resp); n != 1024 {
		t.Fatalf("len=%d, want 1024(上限で切れる)", n)
	}
	if !resp.Close {
		t.Errorf("上限超過後の接続は閉じられるはず(resp.Close=%v)", resp.Close)
	}
}

// R9: 早期拒否した 413 でも、読まなかった本文が残る接続は閉じる。
func TestR9_FastRejectClosesConnection(t *testing.T) {
	srv, _ := setup(t)
	resp := post(t, srv.URL+"/api/json", make([]byte, 2048), false, nil)
	if resp.StatusCode != 413 || !resp.Close {
		t.Fatalf("status=%d close=%v, want 413 と close", resp.StatusCode, resp.Close)
	}
	b, _ := io.ReadAll(resp.Body)
	if !strings.Contains(string(b), "too large") {
		t.Errorf("本文=%q", b)
	}
}

// R10: 圧縮すると膨らむ本文(乱数)。展開後はちょうど上限 64 KiB で合法。
// 圧縮後の Content-Length(上限超)を本文の長さと取り違えると、正しい要求を 413 にしてしまう。
func TestR10_CompressedLengthIsNotBodyLength(t *testing.T) {
	srv, _ := setup(t)
	raw := make([]byte, 64<<10)
	rand.New(rand.NewSource(1)).Read(raw)
	z := gz(t, raw)
	if len(z) <= 64<<10 {
		t.Fatalf("前提が崩れた: 圧縮後 %d が上限以下", len(z))
	}
	r := post(t, srv.URL+"/gz", z, false, map[string]string{"Content-Encoding": "gzip"})
	if r.StatusCode != 200 || gotLen(t, r) != 64<<10 {
		t.Fatalf("status=%d, want 200", r.StatusCode)
	}
}

// R11: 観測。ResponseWriter を包むミドルウェアの内側に BodyLimit を置くと、
// MaxBytesReader が接続を閉じる合図(requestTooLarge)を出せない。
func TestR11_WrappedWriterLosesCloseSignal(t *testing.T) {
	srv, _ := setup(t)
	direct := post(t, srv.URL+"/sloppy", make([]byte, 4096), true, nil)
	wrappedResp := post(t, srv.URL+"/wrapped", make([]byte, 4096), true, nil)
	t.Logf("直接: Close=%v / 包んだ writer 越し: Close=%v", direct.Close, wrappedResp.Close)
	if !direct.Close || wrappedResp.Close {
		t.Errorf("観測が変わった: direct=%v wrapped=%v", direct.Close, wrappedResp.Close)
	}
}

受け入れ条件

  1. go vet ./... と go test -race -count=1 ./... が通る
  2. limit.go は 60 行以内(模範解答は 45 行)
  3. go test の出力を貼って、どのテストがどの行で効いているかを 1 行ずつ書く(書けない行はその行が不要という意味)

💡 ヒント

ヒント1(方向性)

2 段構えです。「申告された長さ」で先に断る段と、「実際に読んだバイト数」で止める段。前者だけでは chunked と嘘の申告に負け、後者だけでは大きな本文を無駄に受け付けます。

ヒント2(アプローチ)
  • r.ContentLength(不明なら -1)と http.MaxBytesReader(w, r.Body, max)
  • 先読みで断るときは、読まない本文が接続に残るので Connection: close を付ける(R9)
  • gzip は gzip.NewReader(r.Body)。展開したら r.ContentLength = -1 にして、ヘッダの Content-Encoding・Content-Length を消す(R10 はこの一行がないと落ちる)
  • 外側が Gunzip、内側が BodyLimit。逆にすると爆弾が通る(R7)
ヒント3(コード骨格)
func BodyLimit(max int64) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if r.ContentLength > max { /* Connection: close を付けて 413 */ }
			r.Body = http.MaxBytesReader(w, r.Body, max)
			next.ServeHTTP(w, r)
		})
	}
}
// Gunzip: Content-Encoding が gzip でなければ素通し。gzip.NewReader が失敗したら 400。

✅ 模範解答

模範解答を表示する(まず問A〜Dの予想を書いてから)

limit.go を次に置き換えます(main.go・main_test.go は変更なし)。

package main

import (
	"compress/gzip"
	"net/http"
)

// BodyLimit は本文の上限 max バイトを守るミドルウェア。
func BodyLimit(max int64) func(http.Handler) http.Handler {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			// 1) 長さが申告されていて超過なら、1 バイトも読まずに断る。
			if r.ContentLength > max {
				w.Header().Set("Connection", "close") // 読まない本文が残る接続は閉じる
				http.Error(w, "payload too large", http.StatusRequestEntityTooLarge)
				return
			}
			// 2) 申告が無い(chunked)・嘘の場合の本命。読んだバイト数で止める。
			r.Body = http.MaxBytesReader(w, r.Body, max)
			next.ServeHTTP(w, r)
		})
	}
}

// Gunzip は Content-Encoding: gzip の本文を展開して next に渡す。
// 展開後のサイズは内側の BodyLimit が見る。不正な gzip は 400。
func Gunzip(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if r.Header.Get("Content-Encoding") != "gzip" {
			next.ServeHTTP(w, r)
			return
		}
		zr, err := gzip.NewReader(r.Body) // ヘッダを読むので、壊れていればここで失敗する
		if err != nil {
			http.Error(w, "bad gzip", http.StatusBadRequest)
			return
		}
		defer zr.Close()
		r.Body = zr
		r.ContentLength = -1 // 圧縮後の長さはもう本文の長さではない
		r.Header.Del("Content-Encoding")
		r.Header.Del("Content-Length")
		next.ServeHTTP(w, r)
	})
}

実行結果(Go 1.27.1、darwin/arm64、実測):

$ go test -race -count=1 -v ./...
--- PASS: TestR1_ContentLengthFastReject
--- PASS: TestR2_ChunkedStopsMidRead
--- PASS: TestR3_UnderLimitPasses
--- PASS: TestR4_Boundary
--- PASS: TestR5_PerRoute
--- PASS: TestR6_AuthBeforeLimit
--- PASS: TestR7_GzipBomb
--- PASS: TestR8_SloppyHandlerSeesTruncation
--- PASS: TestR9_FastRejectClosesConnection
--- PASS: TestR10_CompressedLengthIsNotBodyLength
--- PASS: TestR11_WrappedWriterLosesCloseSignal   (直接: Close=true / 包んだ writer 越し: Close=false)
ok  	day062	4.670s

実サーバー(go build した本物を起動して curl、実測):

100B      → {"len":100}  200
2KiB (CL) → payload too large  413
2KiB (chunked, -H 'Transfer-Encoding: chunked') → payload too large  413
gzip 爆弾(10 MiB のゼロ → 10,221 バイト、Content-Encoding: gzip)→ payload too large  413

🪜 Step-by-Step 解説

Step 1: 先読みで断る(R1・R9)

Content-Length が上限を超えていれば、本文を読まずに 413。ハンドラは呼ばれず、読んだバイト数は 0。ただしクライアントはまだ本文を送ってくる途中かもしれないので、Connection: close を付けて接続ごと捨てます(付けないと R9 が落ちる。変異で確認済み)。

Step 2: 読みながら止める(R2・R4)

chunked は長さ不明(ContentLength == -1)なので先読みできません。MaxBytesReader は「上限 +1 バイト」まで読んで超過を判定します(request.go の Read で p = p[:l.n+1])。だからちょうど 1024 は通り、1025 で初めて失敗します(R4)。ハンドラは 1 回呼ばれ、読んだのは最大 1024 バイトです。

Step 3: エラーを 413 にするのはハンドラ(R8)

MaxBytesReader は *http.MaxBytesError を返すだけです。errors.As で拾って 413 にするのは main.go の readAll の仕事。b, _ := io.ReadAll(r.Body) と書くハンドラは、切り詰められた 1024 バイトを正常な入力として 200 を返します(R8)。この罠はミドルウェアでは直せません。ハンドラ側の規約(readAll のような共通関数に閉じ込める)で防ぎます。

Step 4: 展開後を測る(R7・R10)

Gunzip を外側、BodyLimit を内側に置き、展開された流れに上限をかけます。逆順だと圧縮後の 10 KiB は上限内なので素通りし、ハンドラが 10 MiB を展開します(変異 M7 で R7 が落ちる)。逆に、展開後 ContentLength = -1 に戻さないと、圧縮で膨らむ乱数データ(展開後はちょうど 64 KiB で合法)が圧縮後の長さで先読み拒否されます(R10。変異で確認)。

Step 5: 認証は外側(R6)

/secure は auth(BodyLimit(…))。逆にすると未認証の相手にも本文を読ませ、401 のつもりが 413 になります(情報の出し方としても、読む仕事の無駄としても悪い)。Day 059 のレート制限と同じ原則で、安い判定を外側、本文を触る判定を内側に置きます。

Step 6: ResponseWriter を包むと合図が消える(R11・観測)

MaxBytesReader は w が非公開メソッド requestTooLarge() を持つときだけ接続を閉じる合図を出します。アクセスログなどが struct{ http.ResponseWriter } で包んだ値を渡すと、埋め込みでは非公開メソッドは昇格しないので合図は届きません(Close=true → false、実測)。壊れはしない(残りの本文は Go が読み捨てるか接続を閉じる)が、挙動が静かに変わる例です。Day 060 の「包む側の約束事」と同じ種類の話で、ResponseController と Unwrap を使う理由の 1 つです。

🧭 設計思想・なぜこう書くのか

  • 既定で無制限、上限は明示: Go は「安全な既定値」を標準で隠さず、MaxBytesReader という 1 個の小さな部品を渡して、置く場所(ミドルウェア)は利用者に決めさせます。Server に本文上限の設定が無いのはそのためで、ルートごとに必要な値が 1 KiB から数 GiB まで違うからです。
  • エラーは値: 超過は例外ではなく *MaxBytesError。errors.As で型を見て 413 に写像する。写像を忘れたコードは、コンパイルは通り、テストを書かない限り気づけません。
  • 採らなかった案(前提が変わったら判断が変わる条件つき):
案採らなかった理由(今日の測定)変わる条件
io.LimitReader で切る黙って切り詰める。変異で差し替えると R2・R4・R7・R8 の 4 本が落ちる「先頭 N バイトだけ見たい」用途(ログのサンプリング等)では正解
Content-Length だけで判定chunked が素通り(R2)。嘘の申告にも弱い前段が chunked を拒否し、長さを検証済みと保証できるとき
前段(nginx 等)だけで切るアプリへ直接届く経路で無防備。前段の挙動は未確認アプリが前段経由でしか到達できないと構成で保証されるとき
本文を全部メモリに読んでから長さを見る上限を見る前にメモリを使う。上限の意味がないなし
上限を Server で一括標準に無い。ルートごとに 1 KiB と 1 MiB が混在(R5)全ルート同じ上限なら、1 つのミドルウェアで十分

🌐 他言語との比較

言語・FW本文上限Go との違い
Java(Spring Boot)spring.servlet.multipart.max-request-size 等の設定設定で宣言する。Go はコードで置くので、置き忘れがテストでしか分からない
Python(Django)DATA_UPLOAD_MAX_MEMORY_SIZE(既定 2.5 MB)が既定で効くGo は既定が無制限。安全側の既定が無い代わりに挙動が明示的
TypeScript(Express の body-parser)limit オプション(既定 100kb)。超過は 413 を投げる既定値がある。Go は MaxBytesReader を自分で置く

(上の既定値は各ドキュメントの記憶に基づく。今日は実行も一次資料の再確認もしていないので、使う前に各公式で確認してください。)

🛠️ 実務での使いどころ

  • 公開 API の入口: JSON は 1 MiB 程度、アップロードは別ルートで大きく、というルート別の上限。ルーターの Group 単位で付ける(Day 057 の宣言表に「本文上限」の列を足す形)
  • gzip を受ける Webhook・ログ収集: 展開後の上限は必須。爆弾は圧縮率が 1000 倍超(今日 10 MiB → 10,221 バイト)
  • 共通のハンドラ規約: readAll/json.NewDecoder の後で必ず errors.As(err, &mbe) を見る。レビューの観点に入れる
  • ログ: 413 には上限値と実際に読んだバイト数を残す(調査用)

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
Content-Length を見れば十分普通のクライアントは必ず付けるからchunked には長さが無い。嘘の申告もある(R2)
io.LimitReader で足りる名前が似ていて、サイズ制限に見える黙って切るので壊れた入力を正常扱いする。MaxBytesReader はエラーを返す
MaxBytesReader を置けば 413 が返る名前から「超えたら応答する」と読めるエラーを返すだけ。413 にするのはハンドラ(R8)
圧縮後の長さで上限を判定ヘッダの Content-Length は一番手に入りやすい展開後を測る。順序は Gunzip が外(R7)。展開後は ContentLength = -1(R10)
上限ちょうどは拒否される「上限未満」と思い込むMaxBytesReader は上限ちょうどを許し、+1 で失敗(R4)
ログミドルウェアは中身に影響しない「包むだけ」に見える非公開メソッドの合図が消える(R11)。包む側は Unwrap を実装する

🧬 変異テスト

実装を 1 か所ずつ壊して、どのテストが落ちるかを見ました(10 通り、すべて検出)。

壊し方落ちたテスト
先読み拒否を無効化R1、R6
MaxBytesReader を外すR2、R4、R7、R8
境界 > を >= にR4
Connection: close を外すR9
gzip を展開しないR7
gzip エラーを無視して素通しR7
BodyLimit を Gunzip の外に置く(順序入れ替え)R7
ContentLength = -1 を外すR10(最初のテスト 9 本では生き残った。R10 を足して検出)
auth を BodyLimit の内側へR6
MaxBytesReader を io.LimitReader にR2、R4、R7、R8

🚧 扱わなかった範囲と理由

  • multipart(ParseMultipartForm の maxMemory は「メモリに置く量」で、本文の上限ではない): 別の仕組みなので Day を分ける
  • 本文を読む速度(遅い送信=slowloris): ReadTimeout/Day 061 の領域。今日は大きさだけ
  • ユーザーごとの総量クォータ・ストリーミングで保存先へ流す設計: 単一リクエストの上限とは別の設計
  • HTTP/2・Expect: 100-continue: テストは HTTP/1.1 のみ。100-continue の挙動は未確認
  • R2・R4・R8 のテストが各 0.5 秒かかる理由: 1 リクエストは数 ms(別途 time で計測)なので、srv.Close() 側の待ちと推測しているが、原因は切り分けていない

🚀 次のステップ

  • 発展: Gunzip を ResponseController/Unwrap に対応した共通のラッパに載せ替え、R11 の観測を「Close=true」に直す(ラップしても合図が通る)
  • 次回予告: 同テーマ。state の次回予告(デバッグ)は 10/2 以降の分で扱う

🎯 自己評価

問A〜D の予想と結果

自分の回答

気づき・メモ