Day 067 — 2026-10-07 | ミドルウェア設計パターン: ルートごとのポリシーに入り込んだ 5 つのバグ——グループの継承・共有ポインタ・「-1=上限なし」・二重の数え・鍵の食い違いを、9 本のテストと 11 通りの変異で直す

2026-10-07 🟡 上級者 / Phase 3 デバッグ・コードレビュー ミドルウェア設計パターン
フェーズ: 上級者 (Phase 3) | 問題タイプ: デバッグ・コードレビュー | テーマ: ミドルウェア設計パターン
Day 066 の「宣言表のポリシー+ Route が ctx に置く」設計に、チームメイトが 4 つの新機能(グループ・管理者だけ大きな上限・上限なしの取り込み口・管理画面だけ厳しいレート制限)を足しました。テストは Day 066 の回帰(/healthz)しか無く、全部通っていました。Day 066 の次回予告どおり「デバッグ」。

📚 背景知識(読んでから問題へ)

Day 066 で決めたことを 3 行で振り返ります。

  1. 積む順は 1 本(RequestID → Route → Observe → Recover → Auth → RateLimit → BodyLimit)。ルートごとの違いは値(Policy)で表す
  2. Policy のフィールドは緩める向きの名前(Public・NoRateLimit)にして、ゼロ値=最も厳しい
  3. Role のような締める向きのフィールドはゼロ値で開くので、Route が無いときは誰も持たないロール(denyAll)で閉じる

この設計は「1 本のルートを 1 行で宣言する」ことしか想定していませんでした。現場では次の要求が必ず来ます。

要求世界の実物での書き方今日のコードでの書き方(チームメイト)
接頭辞ごとにまとめる(/admin/* は全部 admin)chi の r.Route("/admin", ...)・r.Group(...)(v5.3.2 mux.go 268〜286 行。Group は With() で親の守りを複製して足す)a.Group("/admin", Policy{Role: "admin"}, mws, fn)——既定ポリシーを子に merge
グループだけ守りを足すchi の Group 内で r.Use(mw)(mux の内側で包む)[]Middleware{a.RateLimitN(2)} をmux の内側で包む
上限なしの口nginx client_max_body_size 0("Setting size to 0 disables checking of client request body size."、既定は 1m)Policy{MaxBody: -1}
一部の利用者だけ上限を上げる—Day 066 で「採らなかった案」に記録したポインタ化(*Policy)を採用し、BodyLimit の中で p.MaxBody = p.AdminMaxBody

どれも「その場では正しく見える」書き方です。今日はこれらが組み合わさったときに何が開くかを探します。

今日の核心: ゼロ値を最も厳しくした設計は「継承」と相性が悪い

Day 066 の Policy{} は「何も書いていない」と「最も厳しく書いた」を区別できません。だからグループの既定 Public: true を子ルートが Public: false で打ち消そうとしても、false は「書いていない」と同じ値になります。merge が def.Public || p.Public になるのは自然で、その結果 /internal/secrets は誰でも読めます(B1)。

この衝突の直し方は 3 つあり、今日は「型で継承できるものを締める向きだけに絞る」を選びます(GroupPolicy{Role, Limit})。他の 2 案(*bool の 3 値・merge の規則を文書で決める)を採らなかった理由は末尾の表に。

参照日 2026-10-07。一次資料: 手元の Go 1.27.1 net/http/request.go 1209〜1214 行(MaxBytesReader: "Treat negative limits as equivalent to 0.")、go-chi/chi v5.3.2 mux.go 242〜286 行(With・Group・Route)と 350 行(Routes()=chi はルート一覧を返せる。net/http.ServeMux には一覧の API が無い)、nginx ngx_http_core_module の client_max_body_size(https://nginx.org/en/docs/http/ngx_http_core_module.html#client_max_body_size 、2026-10-07 取得)。

📝 問題

下のコード(guards.go・policy.go・order.go)には 5 つのバグがあります。受け入れテスト policy_test.go(9 本)を通るように直してください。

やること

  1. テストを流す前に、コードを読んで 5 つのバグを予想する(どのリクエストで何が起きるか、開く側か閉じる側か)
  2. go test -race -count=1 ./... を流し、予想と照らす(バグ入りでは 9 本中 8 本が落ちる。T1 だけ通る)
  3. 1 つずつ直す。直す順番によって、別のバグが表に出てくることがある。どの組がそうか書く
  4. 直し方を 2 つ判断する:
    • グループの既定を子ルートへどう受け継がせるか(merge の規則/型/3 値)
    • 「mux にはあるのにポリシー表に無いルート」に Route は何を置くか

要件(テスト)

テスト内容
T1回帰: /healthz は認証なし・20 回叩いても 429 にならない
T2/internal/version は認証なし 200、/internal/secrets は認証なし 401・alice 403・root 200
T3/admin/users は認証なし 401・alice 403・root 200。403 も GET /admin/users で数える
T4管理画面は 1 利用者 2 回まで(3 回目 429)。全体の予算(5 回)とは別に数える
T5/upload: 一般 10 MiB・admin 40 MiB。admin が使った後も一般は 10 MiB のまま
T6/stream は 20 MiB を受ける(CL 付き・chunked とも)。ただし天井はある(65 MiB は 413)
T7mux に直接登録されポリシー表に無い /rogue は誰も通さない。本当に無い /nope は認証済みに 404
T8root と alice が同時に 30 MiB を送っても alice は全部 413(-race で流す)
T9負の上限(MaxBody: -1)は登録時に止まる

準備

mkdir d67 && cd d67                           # 作業ディレクトリを作って入る
printf 'module d67\n\ngo 1.24\n' > go.mod       # モジュールを作る
# 下の guards.go・policy.go・order.go・policy_test.go を保存する
go test -race -count=1 ./...                  # バグ入りでは T2〜T9 の 8 本が落ちる(実測)

ファイル 1: guards.go(バグ入り)

guards.go 全体(307 行)
package main

import (
	"context"
	"errors"
	"fmt"
	"io"
	"log"
	"net"
	"net/http"
	"runtime/debug"
	"strings"
	"sync"
	"sync/atomic"
)

type Middleware func(http.Handler) http.Handler

// Chain は ms[0] が最も外側になるように h を包む。
func Chain(h http.Handler, ms ...Middleware) http.Handler {
	for i := len(ms) - 1; i >= 0; i-- {
		h = ms[i](h)
	}
	return h
}

type ctxKey int

const (
	idKey ctxKey = iota
	userKey
	routeKey
)

const StatusAborted = 0

type Key struct {
	Route  string
	Status int
}

type App struct {
	Mux      *http.ServeMux
	Log      *log.Logger
	Limit    int
	Roles    map[string]string  // 利用者 → ロール
	policies map[string]*Policy // 登録パターン → ポリシー(Day 066 の値型から、割り当て削減のためポインタへ)
	seq      atomic.Int64
	mu       sync.Mutex
	stats    map[Key]int
	used     map[string]int
}

func NewApp(logger *log.Logger) *App {
	a := &App{Mux: http.NewServeMux(), Log: logger, Limit: 5,
		Roles:    map[string]string{"root": "admin"},
		policies: map[string]*Policy{},
		stats:    map[Key]int{}, used: map[string]int{}}
	ok := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }
	readAll := func(w http.ResponseWriter, r *http.Request) {
		n, err := io.Copy(io.Discard, r.Body)
		var tooBig *http.MaxBytesError
		if errors.As(err, &tooBig) {
			http.Error(w, http.StatusText(413), 413)
			return
		}
		fmt.Fprintf(w, "%d", n)
	}
	a.Handle("GET /healthz", ok, Policy{Public: true, NoRateLimit: true})
	a.Handle("GET /ok", ok, Policy{})
	a.Handle("POST /echo", readAll, Policy{})
	// 管理者だけ 40 MiB まで(新機能)
	a.Handle("POST /upload", readAll, Policy{MaxBody: 10 << 20, AdminMaxBody: 40 << 20})
	// 計測データの取り込み。nginx の client_max_body_size 0 と同じく「上限なし」にしたい(新機能)
	a.Handle("POST /stream", readAll, Policy{MaxBody: -1})

	// 管理画面: admin だけ、しかも 1 利用者 2 回までに絞る(新機能)
	a.Group("/admin", Policy{Role: "admin"}, []Middleware{a.RateLimitN(2)}, func(g *Group) {
		g.Handle("GET /users", ok, Policy{})
		g.Handle("POST /import", readAll, Policy{MaxBody: 10 << 20})
	})
	// 社内向け: 既定は公開。secrets だけ admin に締める(新機能)
	a.Group("/internal", Policy{Public: true, NoRateLimit: true}, nil, func(g *Group) {
		g.Handle("GET /version", ok, Policy{})
		g.Handle("GET /secrets", ok, Policy{Role: "admin"})
	})
	return a
}

func (a *App) Handle(pattern string, h http.HandlerFunc, p Policy) {
	a.Mux.Handle(pattern, h)
	a.policies[pattern] = &p
}

func (a *App) Build(order []string) http.Handler {
	byName := map[string]Middleware{
		"RequestID": a.RequestID, "Route": a.Route, "Observe": a.Observe, "Recover": a.Recover,
		"Auth": a.Auth, "RateLimit": a.RateLimit, "BodyLimit": a.BodyLimit,
	}
	ms := make([]Middleware, 0, len(order))
	for _, n := range order {
		m, ok := byName[n]
		if !ok {
			panic("unknown middleware: " + n)
		}
		ms = append(ms, m)
	}
	return Chain(a.Mux, ms...)
}

func (a *App) Snapshot() map[Key]int {
	a.mu.Lock()
	defer a.mu.Unlock()
	out := make(map[Key]int, len(a.stats))
	for k, v := range a.stats {
		out[k] = v
	}
	return out
}

func idOf(r *http.Request) string {
	if v, ok := r.Context().Value(idKey).(string); ok {
		return v
	}
	return "-"
}

func userOf(r *http.Request) string {
	u, _ := r.Context().Value(userKey).(string)
	return u
}

func (a *App) RequestID(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		id := fmt.Sprintf("req-%d", a.seq.Add(1))
		w.Header().Set("X-Request-Id", id)
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), idKey, id)))
	})
}

type statusWriter struct {
	http.ResponseWriter
	status int
}

func (w *statusWriter) WriteHeader(c int) {
	if w.status == 0 {
		w.status = c
	}
	w.ResponseWriter.WriteHeader(c)
}

func (w *statusWriter) Write(b []byte) (int, error) {
	if w.status == 0 {
		w.status = 200
	}
	return w.ResponseWriter.Write(b)
}

func (w *statusWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

// Observe はルート名を Route が ctx に置いた値から読む。
func (a *App) Observe(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		route := a.resolve(r).Pattern
		if route == "" {
			route = "unmatched"
		}
		sw := &statusWriter{ResponseWriter: w}
		completed := false
		defer func() {
			st := sw.status
			if st == 0 {
				st = 200
			}
			if !completed {
				st = StatusAborted
			}
			a.mu.Lock()
			a.stats[Key{route, st}]++
			a.mu.Unlock()
		}()
		next.ServeHTTP(sw, r)
		completed = true
	})
}

func (a *App) Recover(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		sw := &statusWriter{ResponseWriter: w}
		defer func() {
			v := recover()
			if v == nil {
				return
			}
			if v == http.ErrAbortHandler {
				panic(v)
			}
			a.Log.Printf("panic: %v id=%s %s %s\n%s", v, idOf(r), r.Method, r.URL.Path, debug.Stack())
			if sw.status != 0 {
				panic(http.ErrAbortHandler)
			}
			http.Error(sw, http.StatusText(500), 500)
		}()
		next.ServeHTTP(sw, r)
	})
}

func parseToken(h string) (string, bool) {
	t, ok := strings.CutPrefix(h, "Bearer ")
	if !ok {
		return "", false
	}
	parts := strings.Split(t, ".")
	if len(parts) != 2 || parts[0] != "u" {
		return "", false
	}
	return parts[1], true
}

func (a *App) Auth(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := a.resolve(r).Policy
		if p.Public {
			next.ServeHTTP(w, r)
			return
		}
		user, ok := parseToken(r.Header.Get("Authorization"))
		if !ok {
			http.Error(w, http.StatusText(401), 401)
			return
		}
		if p.Role != "" && a.Roles[user] != p.Role {
			http.Error(w, http.StatusText(403), 403)
			return
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userKey, user)))
	})
}

func (a *App) clientKey(r *http.Request) string {
	if u := userOf(r); u != "" {
		return "user:" + u
	}
	if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
		return "ip:" + host
	}
	return "ip:" + r.RemoteAddr
}

// take は key の予算を 1 つ使い、limit を超えたら false を返す。
func (a *App) take(key string, limit int) bool {
	a.mu.Lock()
	defer a.mu.Unlock()
	a.used[key]++
	return a.used[key] <= limit
}

func tooMany(w http.ResponseWriter) {
	w.Header().Set("Retry-After", "1")
	http.Error(w, http.StatusText(429), 429)
}

func (a *App) RateLimit(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		if a.resolve(r).Policy.NoRateLimit {
			next.ServeHTTP(w, r)
			return
		}
		if !a.take(a.clientKey(r), a.Limit) {
			tooMany(w)
			return
		}
		next.ServeHTTP(w, r)
	})
}

// RateLimitN はグループ専用の、より厳しい制限(Group の内側に挟む)。
func (a *App) RateLimitN(n int) Middleware {
	return func(next http.Handler) http.Handler {
		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
			if !a.take(a.clientKey(r), n) {
				tooMany(w)
				return
			}
			next.ServeHTTP(w, r)
		})
	}
}

func (a *App) BodyLimit(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		p := a.resolve(r).Policy
		if p.AdminMaxBody > 0 && a.Roles[userOf(r)] == "admin" {
			p.MaxBody = p.AdminMaxBody // 管理者は上限を引き上げる
		}
		max := p.maxBody()
		if r.ContentLength > max {
			http.Error(w, http.StatusText(413), 413)
			return
		}
		r.Body = http.MaxBytesReader(w, r.Body, max)
		next.ServeHTTP(w, r)
	})
}

func main() {}

ファイル 2: policy.go(バグ入り)

package main

import (
	"context"
	"net/http"
	"strings"
)

const defaultMaxBody = 1 << 20

// Policy はルートごとの守り方。ゼロ値がいちばん厳しい(Day 066)。
type Policy struct {
	Public       bool   // true なら認証しない
	NoRateLimit  bool   // true ならレート制限しない
	MaxBody      int64  // 0 なら defaultMaxBody。-1 なら上限なし
	AdminMaxBody int64  // admin ロールのときの上限。0 なら MaxBody と同じ
	Role         string // "" ならロール不問
}

func (p *Policy) maxBody() int64 {
	if p.MaxBody != 0 {
		return p.MaxBody
	}
	return defaultMaxBody
}

type routeInfo struct {
	Pattern string
	Policy  *Policy
}

// Route は mux と同じ照合を 1 回だけ行い、パターンとポリシーを ctx に置く。
func (a *App) Route(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, pattern := a.Mux.Handler(r)
		p, ok := a.policies[pattern]
		if !ok {
			pattern, p = "", &Policy{} // 未登録・405 は最も厳しいポリシー
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), routeKey, routeInfo{pattern, p})))
	})
}

const denyAll = "\x00route-missing"

// resolve は Route が置いた値を返す。Route が積まれていなければ誰も通さない。
func (a *App) resolve(r *http.Request) routeInfo {
	if ri, ok := r.Context().Value(routeKey).(routeInfo); ok {
		return ri
	}
	return routeInfo{Policy: &Policy{Role: denyAll}}
}

// Group は接頭辞と既定のポリシーを共有するルートのまとまり(chi の Group/Route に倣った)。
type Group struct {
	a      *App
	prefix string
	def    Policy
	mws    []Middleware
}

func (a *App) Group(prefix string, def Policy, mws []Middleware, fn func(g *Group)) {
	fn(&Group{a: a, prefix: prefix, def: def, mws: mws})
}

// Handle は "GET /users" を "GET /admin/users" として登録する。
func (g *Group) Handle(pattern string, h http.HandlerFunc, p Policy) {
	method, path, _ := strings.Cut(pattern, " ")
	full := method + " " + g.prefix + path
	g.a.Mux.Handle(full, Chain(h, g.mws...))
	merged := merge(g.def, p)
	g.a.policies[g.prefix+pattern] = &merged
}

// merge はグループの既定にルートの指定を重ねる。
func merge(def, p Policy) Policy {
	out := p
	out.Public = def.Public || p.Public
	out.NoRateLimit = def.NoRateLimit || p.NoRateLimit
	if out.Role == "" {
		out.Role = def.Role
	}
	if out.MaxBody == 0 {
		out.MaxBody = def.MaxBody
	}
	return out
}

ファイル 3: order.go

package main

// Order は外→内の積む順(Day 066 と同じ)。
var Order = []string{"RequestID", "Route", "Observe", "Recover", "Auth", "RateLimit", "BodyLimit"}

ファイル 4: policy_test.go(受け入れテスト。変えない)

policy_test.go 全体(T1〜T9)
package main

import (
	"bytes"
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"sync"
	"testing"
)

const (
	alice = "Bearer u.alice"
	root  = "Bearer u.root"
	MiB   = 1 << 20
)

type env struct {
	app *App
	h   http.Handler
	log *bytes.Buffer
}

func newEnv() *env {
	buf := &bytes.Buffer{}
	app := NewApp(log.New(buf, "", 0))
	return &env{app: app, h: app.Build(Order), log: buf}
}

type zeros struct{}

func (zeros) Read(p []byte) (int, error) { clear(p); return len(p), nil }

// do は 1 リクエストを流す。body>0 なら body バイトのゼロを送り、chunked なら Content-Length を伏せる。
func (e *env) do(method, path, auth string, body int64, chunked bool) *httptest.ResponseRecorder {
	req := httptest.NewRequest(method, path, nil)
	if body > 0 {
		req.Body = io.NopCloser(io.LimitReader(zeros{}, body))
		req.ContentLength = body
		if chunked {
			req.ContentLength = -1
		}
	}
	req.RemoteAddr = "203.0.113.7:4000"
	if auth != "" {
		req.Header.Set("Authorization", auth)
	}
	rec := httptest.NewRecorder()
	e.h.ServeHTTP(rec, req)
	return rec
}

type req struct {
	m, p, a string
	body    int64
	chunked bool
	want    int
}

func expect(t *testing.T, e *env, reqs ...req) {
	t.Helper()
	for i, c := range reqs {
		if got := e.do(c.m, c.p, c.a, c.body, c.chunked).Code; got != c.want {
			t.Fatalf("#%d %s %s auth=%q body=%d chunked=%v: code=%d want %d",
				i+1, c.m, c.p, c.a, c.body, c.chunked, got, c.want)
		}
	}
}

// T1 回帰: /healthz は認証なし・制限なし(Day 066 の P1・P2)
func TestT1_HealthzStaysOpen(t *testing.T) {
	e := newEnv()
	for i := 0; i < 20; i++ {
		expect(t, e, req{"GET", "/healthz", "", 0, false, 200})
	}
}

// T2 /internal/version は公開、/internal/secrets は admin だけ
func TestT2_InternalGroup(t *testing.T) {
	e := newEnv()
	expect(t, e,
		req{"GET", "/internal/version", "", 0, false, 200},
		req{"GET", "/internal/secrets", "", 0, false, 401},
		req{"GET", "/internal/secrets", alice, 0, false, 403},
		req{"GET", "/internal/secrets", root, 0, false, 200},
	)
}

// T3 /admin/users は admin だけ。403 もルート名で数える
func TestT3_AdminGroup(t *testing.T) {
	e := newEnv()
	expect(t, e,
		req{"GET", "/admin/users", "", 0, false, 401},
		req{"GET", "/admin/users", alice, 0, false, 403},
		req{"GET", "/admin/users", root, 0, false, 200},
	)
	if s := e.app.Snapshot(); s[Key{"GET /admin/users", 403}] != 1 {
		t.Fatalf("stats=%v", s)
	}
}

// T4 管理画面は 1 利用者 2 回まで。全体の予算(5 回)とは別に数える
func TestT4_AdminBudget(t *testing.T) {
	e := newEnv()
	expect(t, e,
		req{"GET", "/admin/users", root, 0, false, 200},
		req{"GET", "/admin/users", root, 0, false, 200},
		req{"GET", "/admin/users", root, 0, false, 429},
	)
	for i := 0; i < 5; i++ {
		expect(t, e, req{"GET", "/ok", root, 0, false, 200})
	}
	expect(t, e, req{"GET", "/ok", root, 0, false, 429})
}

// T5 /upload: 一般 10 MiB・admin 40 MiB。admin が使った後も一般の上限は 10 MiB のまま
func TestT5_UploadAdminBonusDoesNotLeak(t *testing.T) {
	e := newEnv()
	e.app.Limit = 1000
	expect(t, e,
		req{"POST", "/upload", alice, 5 * MiB, false, 200},
		req{"POST", "/upload", alice, 11 * MiB, false, 413},
		req{"POST", "/upload", root, 30 * MiB, false, 200},
		req{"POST", "/upload", alice, 30 * MiB, false, 413},
		req{"POST", "/upload", alice, 11 * MiB, true, 413},
	)
}

// T6 /stream は 20 MiB を受ける(Content-Length 付き・chunked とも)。ただし天井はある(65 MiB は 413)
func TestT6_StreamCeiling(t *testing.T) {
	e := newEnv()
	e.app.Limit = 1000
	expect(t, e,
		req{"POST", "/stream", alice, 20 * MiB, false, 200},
		req{"POST", "/stream", alice, 20 * MiB, true, 200},
		req{"POST", "/stream", alice, 65 * MiB, true, 413},
		req{"POST", "/echo", alice, 2 * MiB, true, 413},
	)
}

// T7 mux に登録されているのにポリシー表に無いルートは、誰も通さない
func TestT7_RouteWithoutPolicyIsClosed(t *testing.T) {
	e := newEnv()
	e.app.Mux.HandleFunc("GET /rogue", func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "secret") })
	expect(t, e,
		req{"GET", "/rogue", "", 0, false, 401},
		req{"GET", "/rogue", alice, 0, false, 403},
		req{"GET", "/rogue", root, 0, false, 403},
		req{"GET", "/nope", alice, 0, false, 404}, // 本当に無いルートは、認証済みの利用者には 404 で打ち間違いを知らせる
	)
}

// T8 admin と一般利用者が同時にアップロードしても、一般の上限は 10 MiB のまま(-race で流す)
func TestT8_ConcurrentUploads(t *testing.T) {
	e := newEnv()
	e.app.Limit = 1000
	var wg sync.WaitGroup
	var mu sync.Mutex
	var leaked []string
	for i := 0; i < 8; i++ {
		wg.Add(2)
		go func() { defer wg.Done(); e.do("POST", "/upload", root, 30*MiB, false) }()
		go func(i int) {
			defer wg.Done()
			if c := e.do("POST", "/upload", alice, 30*MiB, true).Code; c != 413 {
				mu.Lock()
				leaked = append(leaked, fmt.Sprintf("alice#%d=%d", i, c))
				mu.Unlock()
			}
		}(i)
	}
	wg.Wait()
	if len(leaked) > 0 {
		t.Fatalf("一般利用者の 30 MiB が通った: %v", leaked)
	}
}

// T9 負の上限(「上限なし」のつもりの -1 など)は登録時に止まる
func TestT9_NegativeLimitRejectedAtStartup(t *testing.T) {
	e := newEnv()
	defer func() {
		if recover() == nil {
			t.Fatal("MaxBody: -1 が登録できてしまった")
		}
	}()
	e.app.Handle("POST /x", func(http.ResponseWriter, *http.Request) {}, Policy{MaxBody: -1})
}

受け入れ条件

  • go test -race -count=1 ./... が 5 回連続で通る
  • 5 つのバグそれぞれについて「どのテストで見つかるか」「開く側か閉じる側か」を 1 行で書く
  • 手順 4 の 2 つの判断について、採らなかった案と理由を 1 行ずつ書く

💡 ヒント

ヒント1(方向性)

新機能 4 つは、それぞれ Day 066 の約束を 1 つずつ破っています。「積む順は 1 本」「照合は 1 回、mux と同じ文字列で」「ゼロ値=最も厳しい」「ctx から読んだ値は読むだけ」。どの新機能がどれを破っているかを対応づけると 5 つ見つかります(1 つの新機能に 2 つ入っているものがある)。

ヒント2(アプローチ)
  • Group.Handle が mux に書く文字列と、a.policies に書く文字列を並べて比べる。fmt.Println で表のキーを全部出すのが一番早い
  • MaxBody: -1 は r.ContentLength > max と http.MaxBytesReader(w, body, -1) の 2 か所でどう振る舞うか。GET(ContentLength 0)・CL 付き・chunked(ContentLength -1)の 3 つで考える
  • RateLimitN の中の a.take(a.clientKey(r), n) と外側の RateLimit の a.take(...) は、同じ財布を使っていないか
  • p := a.resolve(r).Policy の型は何か。p.MaxBody = ... は何を書き換えているか
ヒント3(コード骨格)
// 1. mux と表に「同じ文字列」で書く入口を 1 つにする
func (a *App) register(pattern string, h http.Handler, p Policy) {
	if err := p.validate(); err != nil { panic(...) } // 負の値を起動時に止める
	a.Mux.Handle(pattern, h)
	a.policies[pattern] = p // 値で持つ
}

// 2. グループが受け継がせてよいのは締める向きだけ(型で制限)
type GroupPolicy struct { Role string; Limit int }

// 3. Route: mux にあるのに表に無い → 閉じる
switch {
case ok:
case pattern == "": p = Policy{}  // 本当に無い: 認証を先に、通れば mux が 404/405
default:            p = closed    // 食い違い
}

ルート専用の予算は Policy.Limit に移し、外側の RateLimit が「利用者+パターン」の別の財布で 1 回だけ数える。RateLimitN は消す。

✅ 模範解答

5 つのバグ(実測した症状)

#バグ破った約束症状(バグ入りで実測)倒れる側見つけるテスト
B1グループの既定を def.Public || p.Public で merge。子は Public: false を表せないゼロ値=最も厳しい(「書いていない」と「締めた」を区別できない)/internal/secrets が認証なしで 200(B5 を直した後に表に出る)開くT2
B2*Policy を共有し、BodyLimit が p.MaxBody = p.AdminMaxBody と書き換えるctx から読んだ値は読むだけroot が 1 回 30 MiB を送った後、alice の 30 MiB が 200。同時実行では alice 160 本中 134 本が 200(20 回中 20 回で落ちる)。-race は guards.go:295 の書き込み同士の競合を報告開く(しかも順序次第で揺れる)T5・T8
B3MaxBody: -1 を「上限なし」の意味で使った(nginx の 0 の感覚)表せない値は登録時に止める/stream は1 バイトでも 413: CL 付きは 1 > -1、chunked は MaxBytesReader が負を 0 として扱う(request.go 1210 行)閉じる(大きな音)T6・T9
B4管理画面の制限を mux の内側の RateLimitN(2) で足した。外側の RateLimit と同じ鍵で数える積む順は 1 本/1 リクエストは 1 回だけ数えるroot の 1 回目で used=2、2 回目で 429(used=4 > 2)。全体の予算も 2 倍の速さで減る閉じる(早すぎる 429)T4
B5Group.Handle が mux には "GET /admin/users"、表には "/adminGET /users" と書く。Route は表に無いと Policy{} を置く照合は mux と同じ文字列で/締める向きのゼロ値は開く**alice が /admin/users を 200 で取れる**(Role が空になる)。/internal/version は 401、/admin/import の 10 MiB 指定も消えて 5 MiB が 413開く(admin)+閉じる(version)T2・T3・T4・T7

B5 が B1 を隠していた(手順 3 の答え): バグ入りでは /internal/secrets の表のキーも食い違っているので、Route は Policy{}=認証ありを置き、認証なしは 401 になります。B1 があるのに T2 の secrets の行は通ってしまう。B5 だけ直すと、merge 済みの Public: true が正しく引かれ、secrets が認証なしで 200 に変わります(変異 M1・M6 で実測)。「1 つ直したら別の穴が開いた」は、直し方が悪いのではなく、隠れていた穴が見えるようになっただけです。

policy.go(模範解答)

package main

import (
	"context"
	"fmt"
	"net/http"
	"strings"
)

const defaultMaxBody = 1 << 20

// Policy はルートごとの守り方。ゼロ値がいちばん厳しい(Day 066)。
// 値型で持ち回す: ctx から取り出した時点で複製なので、守りの中で書き換えても表は変わらない(B2)。
type Policy struct {
	Public       bool   // true なら認証しない
	NoRateLimit  bool   // true ならレート制限しない
	MaxBody      int64  // 0 なら defaultMaxBody。負の値は登録時に panic(B3)
	AdminMaxBody int64  // admin ロールのときの上限。0 なら MaxBody と同じ
	Role         string // "" ならロール不問
	Limit        int    // このルート専用の予算(利用者×ルートで数える)。0 なら App.Limit を全体で共有(B4)
}

func (p Policy) maxBody() int64 {
	if p.MaxBody > 0 {
		return p.MaxBody
	}
	return defaultMaxBody
}

// maxBodyFor は利用者のロールを見た上限。p を書き換えずに返す(B2)。
func (p Policy) maxBodyFor(role string) int64 {
	if role == "admin" && p.AdminMaxBody > 0 {
		return p.AdminMaxBody
	}
	return p.maxBody()
}

func (p Policy) validate() error {
	if p.MaxBody < 0 || p.AdminMaxBody < 0 || p.Limit < 0 {
		return fmt.Errorf("負の値は意味を持たない(上限なしは表せない): %+v", p)
	}
	return nil
}

type routeInfo struct {
	Pattern string
	Policy  Policy
}

const denyAll = "\x00route-missing"

// closed はどの利用者も持たないロールを要求する、誰も通さないポリシー。
var closed = Policy{Role: denyAll}

// Route は mux と同じ照合を 1 回だけ行い、パターンとポリシーを ctx に置く。
func (a *App) Route(next http.Handler) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		_, pattern := a.Mux.Handler(r)
		p, ok := a.policies[pattern]
		switch {
		case ok:
		case pattern == "":
			p = Policy{} // 未登録・405: 認証を先に求め、通れば mux が 404/405 を返す
		default:
			p = closed // mux にはあるのに表に無い=登録の食い違い。開かずに閉じる(B5)
		}
		next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), routeKey, routeInfo{pattern, p})))
	})
}

// resolve は Route が置いた値を返す。Route が積まれていなければ誰も通さない。
func (a *App) resolve(r *http.Request) routeInfo {
	if ri, ok := r.Context().Value(routeKey).(routeInfo); ok {
		return ri
	}
	return routeInfo{Policy: closed}
}

// register は mux とポリシー表に「同じ文字列」で書く唯一の入口(B5)。
func (a *App) register(pattern string, h http.Handler, p Policy) {
	if err := p.validate(); err != nil {
		panic(pattern + ": " + err.Error())
	}
	a.Mux.Handle(pattern, h)
	a.policies[pattern] = p
}

// GroupPolicy はグループが子ルートへ受け継がせてよいものだけを持つ。
// 締める向き(Role・Limit)だけ。緩める向き(Public・NoRateLimit・MaxBody)は型に無いので継承できない(B1)。
type GroupPolicy struct {
	Role  string
	Limit int
}

type Group struct {
	a      *App
	prefix string
	def    GroupPolicy
}

// Group は接頭辞と既定を共有するルートのまとまり。守りは足さない(積む順は Order の 1 本だけ。B4)。
func (a *App) Group(prefix string, def GroupPolicy, fn func(g *Group)) {
	fn(&Group{a: a, prefix: prefix, def: def})
}

// Handle は "GET /users" を "GET /admin/users" として登録する。
func (g *Group) Handle(pattern string, h http.HandlerFunc, p Policy) {
	method, path, _ := strings.Cut(pattern, " ")
	if p.Role == "" {
		p.Role = g.def.Role
	}
	if p.Limit == 0 {
		p.Limit = g.def.Limit
	}
	g.a.register(method+" "+g.prefix+path, h, p)
}

guards.go の変更(バグ入りとの差分)

--- bug/guards.go	2026-10-07 06:00:06
+++ fix/guards.go	2026-10-07 06:02:10
@@ -43,8 +43,8 @@
 	Mux      *http.ServeMux
 	Log      *log.Logger
 	Limit    int
-	Roles    map[string]string  // 利用者 → ロール
-	policies map[string]*Policy // 登録パターン → ポリシー(Day 066 の値型から、割り当て削減のためポインタへ)
+	Roles    map[string]string // 利用者 → ロール
+	policies map[string]Policy // 登録パターン → ポリシー(値型に戻す。B2)
 	seq      atomic.Int64
 	mu       sync.Mutex
 	stats    map[Key]int
@@ -54,7 +54,7 @@
 func NewApp(logger *log.Logger) *App {
 	a := &App{Mux: http.NewServeMux(), Log: logger, Limit: 5,
 		Roles:    map[string]string{"root": "admin"},
-		policies: map[string]*Policy{},
+		policies: map[string]Policy{},
 		stats:    map[Key]int{}, used: map[string]int{}}
 	ok := func(w http.ResponseWriter, r *http.Request) { io.WriteString(w, "ok") }
 	readAll := func(w http.ResponseWriter, r *http.Request) {
@@ -71,25 +71,24 @@
 	a.Handle("POST /echo", readAll, Policy{})
 	// 管理者だけ 40 MiB まで(新機能)
 	a.Handle("POST /upload", readAll, Policy{MaxBody: 10 << 20, AdminMaxBody: 40 << 20})
-	// 計測データの取り込み。nginx の client_max_body_size 0 と同じく「上限なし」にしたい(新機能)
-	a.Handle("POST /stream", readAll, Policy{MaxBody: -1})
+	// 計測データの取り込み。「上限なし」は表せないので、運用上の天井を数字で書く(B3)
+	a.Handle("POST /stream", readAll, Policy{MaxBody: 64 << 20})
 
 	// 管理画面: admin だけ、しかも 1 利用者 2 回までに絞る(新機能)
-	a.Group("/admin", Policy{Role: "admin"}, []Middleware{a.RateLimitN(2)}, func(g *Group) {
+	a.Group("/admin", GroupPolicy{Role: "admin", Limit: 2}, func(g *Group) {
 		g.Handle("GET /users", ok, Policy{})
 		g.Handle("POST /import", readAll, Policy{MaxBody: 10 << 20})
 	})
-	// 社内向け: 既定は公開。secrets だけ admin に締める(新機能)
-	a.Group("/internal", Policy{Public: true, NoRateLimit: true}, nil, func(g *Group) {
-		g.Handle("GET /version", ok, Policy{})
+	// 社内向け: 公開はルートごとに書く(グループからは継承できない。B1)
+	a.Group("/internal", GroupPolicy{}, func(g *Group) {
+		g.Handle("GET /version", ok, Policy{Public: true, NoRateLimit: true})
 		g.Handle("GET /secrets", ok, Policy{Role: "admin"})
 	})
 	return a
 }
 
 func (a *App) Handle(pattern string, h http.HandlerFunc, p Policy) {
-	a.Mux.Handle(pattern, h)
-	a.policies[pattern] = &p
+	a.register(pattern, h, p)
 }
 
 func (a *App) Build(order []string) http.Handler {
@@ -263,11 +262,16 @@
 
 func (a *App) RateLimit(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		if a.resolve(r).Policy.NoRateLimit {
+		ri := a.resolve(r)
+		if ri.Policy.NoRateLimit {
 			next.ServeHTTP(w, r)
 			return
 		}
-		if !a.take(a.clientKey(r), a.Limit) {
+		key, limit := a.clientKey(r), a.Limit
+		if ri.Policy.Limit > 0 { // ルート専用の予算は別の財布で数える(1 リクエスト=1 回だけ数える。B4)
+			key, limit = key+" "+ri.Pattern, ri.Policy.Limit
+		}
+		if !a.take(key, limit) {
 			tooMany(w)
 			return
 		}
@@ -275,26 +279,9 @@
 	})
 }
 
-// RateLimitN はグループ専用の、より厳しい制限(Group の内側に挟む)。
-func (a *App) RateLimitN(n int) Middleware {
-	return func(next http.Handler) http.Handler {
-		return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-			if !a.take(a.clientKey(r), n) {
-				tooMany(w)
-				return
-			}
-			next.ServeHTTP(w, r)
-		})
-	}
-}
-
 func (a *App) BodyLimit(next http.Handler) http.Handler {
 	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
-		p := a.resolve(r).Policy
-		if p.AdminMaxBody > 0 && a.Roles[userOf(r)] == "admin" {
-			p.MaxBody = p.AdminMaxBody // 管理者は上限を引き上げる
-		}
-		max := p.maxBody()
+		max := a.resolve(r).Policy.maxBodyFor(a.Roles[userOf(r)])
 		if r.ContentLength > max {
 			http.Error(w, http.StatusText(413), 413)
 			return

実測の数字(Go 1.27.1、Apple M1 8 GB)

項目値出所
バグ入りで落ちるテスト9 本中 8 本(T1 だけ通る)実測(go test -count=1)
模範解答9 本すべて PASS。-race -count=1 を 5 回連続で PASS(1 回 1.3〜1.7 秒)実測
B2 の揺れT8(root 8 本+alice 8 本を同時に)を 20 回: 20 回とも落ち、alice 160 本中 134 本が 200(84%)。-race では競合を報告実測(-count=20)
変異 11 通り11 通りすべて検出(下の表)。最初の版では M7 が生き残った実測(mut.py で 1 か所ずつ戻して -race で流す)
割り当て(1 リクエスト)/ok 7 段: ポインタ版 1,768 B・20 allocs → 値版 1,808 B・20 allocs。Route 単体: 392 B → 432 B(どちらも 3 allocs)実測(-benchmem、3 回とも同じ値)。値に戻す費用は 40 B・0 allocs
時間比較に使わない(計測中のロードアベレージ 7.5〜17.5。Route 単体が 384〜600 ns と揺れた)実測(uptime)

Day 066 で「*routeInfo にすれば 48 B・1 alloc 減る」と書いたポインタ化を、チームメイトは *Policy(routeInfo の中のポインタ)で採りました。実測では allocs は減らず 40 B だけ——routeInfo が context.WithValue で interface に詰められるとき 1 回割り当てられるのは同じだからです。得はほぼ無く、書き換えの穴だけが残った。

変異(1 か所ずつ戻す)

変異結果落ちたテスト
M1 B1 だけ戻す(GroupPolicy に Public を足して OR で継承)検出T2(secrets 認証なし 200)
M2 B2 だけ戻す(*Policy で共有+書き換え)検出T5・T8+-race の競合報告
M3 B3 だけ戻す(-1・!= 0・検査なし)検出T6・T9
M3b -1 を書くが登録時の検査は残す検出起動時に panic(全テストが止まる=本番なら起動しない)
M3c 登録時の検査だけ外す検出T9
M4 ルート専用の予算を全体と同じ財布で数える検出T4(root の /ok が 3 回目で 429)
M5 B5 だけ戻す(鍵の食い違い+既定ゼロ値)検出T2・T3・T4・T7
M5a 鍵の食い違いだけ(既定は closed のまま)検出T2・T3・T4(root も 403=閉じる側に倒れた)
M5b 既定をゼロ値に(鍵は一致)検出T7(/rogue を alice が 200)
M6 B5 と B1 を同時に戻す検出T2・T3・T4・T7(T2 は version の 401 で落ち、secrets の穴は隠れる)
M7 未登録(pattern が空)にも closed を置く検出T7(alice の /nope が 404 でなく 403)

M7 は最初の版で生き残りました(T7 に /nope の行が無かった)。「本当に無いルート」と「表から漏れたルート」を同じに扱っても安全側には倒れますが、認証済みの利用者は打ち間違いでも 403 を受け、原因が分かりません。pattern == ""(mux も知らない)と pattern != ""(mux は知っている)を分ける理由を、テストの 1 行で固定しました。M5a と M5b の 2 つが別々に検出されることも大事です——鍵を揃える(register)と揃わなかったときに閉じる(closed)は別の守りで、片方だけでは、もう片方が壊れたときに気づけません。

🪜 Step-by-Step 解説

Step 1: 表のキーを全部出す(B5)

最初にやるのは推理ではなく表の中身を見ることです。バグ入りで a.policies のキーを出した実測:

"GET /healthz"  "GET /ok"  "POST /echo"  "POST /upload"  "POST /stream"
"/adminGET /users"  "/adminPOST /import"  "/internalGET /version"  "/internalGET /secrets"

グループの 4 本が mux と違う文字列で入っています。Route は mux.Handler で "GET /admin/users" を得て表を引き、無いので Policy{} を置く。Day 066 の Step 3 と同じ穴——**Role は締める向きなので、ゼロ値では開く**——が、今度は「Route を積み忘れた」ではなく「表に書き忘れた」経路で出ました。

直し方は 2 段です。

  1. 書く入口を 1 つにする(register): mux と表に同じ変数で書くので、食い違いようがない
  2. 食い違っても閉じる(closed): register を通らずに a.Mux.Handle を直接呼ぶ人は必ず出る(T7)。mux が知っているのに表が知らないなら、それは誤りなので誰も通さない

net/http.ServeMux は登録済みパターンの一覧を返す API を持たないので、「起動時に mux と表を突き合わせる」検査はできません。chi は Routes()(mux.go 350 行)で一覧を返せるので、chi なら起動時検査が書けます。標準の mux では 2 の実行時の既定が最後の砦です。

Step 2: 継承は締める向きだけ(B1)

B5 を直すと T2 の落ち方が変わります(version の 401 → secrets の 200)。merge の out.Public = def.Public || p.Public は、Policy のゼロ値が「何も書いていない」と「認証あり」を兼ねている以上、他に書きようがありません。

GroupPolicy を別の型にし、Role と Limit だけを持たせました。緩める指定は型に無いので継承できない。公開したいルートは 1 本ずつ Public: true と書くので、Day 066 の「grep 'Public: true' で緩めている箇所の一覧が出る」が保たれます。

Step 3: 読んだ値は読むだけ(B2)

p := a.resolve(r).Policy は、型が *Policy なら共有の表そのものです。p.MaxBody = p.AdminMaxBody は表を書き換え、以後すべての利用者の /upload が 40 MiB になります。同時に動く 2 つのリクエストが同じ場所に書くのでデータ競合でもあります。

直し方は 2 つを重ねました。

  • 値型に戻す: ctx から取り出した時点で複製なので、書き換えても表は変わらない(費用は実測 40 B・0 allocs)
  • 書き換えずに計算する: maxBodyFor(role) が上限を返す。値型でも「ポリシーを書き換えて意味を変える」書き方そのものをやめる

Step 4: 「上限なし」を数字で書かない(B3)

nginx では client_max_body_size 0 が「検査しない」です。Go の MaxBytesReader は負の上限を 0 として扱います(request.go 1210 行)。今日のコードは 0 を「既定の 1 MiB」に使っていました。同じ数字が、道具ごとに逆の意味を持つ——これが B3 の根です。-1 は r.ContentLength > -1 で CL 付きをすべて 413 にし、chunked は MaxBytesReader(-1)=0 バイトで 413 にします。今回は閉じる側に倒れたので被害は「取り込みが全部止まる」でしたが、もし maxBody() が -1 を math.MaxInt64 に変換していたら、本当に上限なしになっていました。

直し方は「上限なし」を表せなくすること: 負の値は登録時に panic(T9)、/stream には運用上の天井 64 MiB を数字で書く。

Step 5: 守りは 1 本、財布は分ける(B4)

chi の Group + Use に倣った「グループだけ mux の内側で守りを足す」は、Day 065 で縛った積む順の外にもう 1 本の列を作ります。さらに RateLimitN は外側の RateLimit と同じ a.used の同じ鍵で数えるので、管理画面のリクエストは 1 回で 2 つ減ります(実測 used=map[user:root:2])。

ルート専用の予算を Policy.Limit に移し、外側の RateLimit 1 つが「利用者+パターン」という別の財布で数えるようにしました。グループは守りを足す手段を持たない(Group の引数から []Middleware を消した)ので、積む順は Order の 1 本に戻ります。

⚖️ 迷った他案と採らなかった理由

論点採った案採らなかった案と理由前提が変わったら
グループの継承**GroupPolicy{Role, Limit}——締める向きだけ型で許す*****bool の 3 値(nil=継承)**: Public: ptr(false) で打ち消せるが、宣言表が ptr(true) だらけになり、nil 参照の扱いを守り全部に書く。merge 規則を文書で決める: 規則は書けても、false と「未指定」を区別できない型のままでは守れないグループごとに公開・非公開を切り替える要件が多い(社内ツールで数十ルートが全部公開等)なら、3 値にしてもよい
表に無いルート**pattern != "" なら closed、"" なら Policy{}**両方 closed(M7): 安全だが認証済みの打ち間違いに 403 で、原因が分からない。起動時に突き合わせる: 標準の ServeMux は一覧を返さないのでできないchi に移るなら Routes() で起動時検査を足し、実行時の closed は保険に
管理者の上限**maxBodyFor(role) で計算(値型)**ポインタのまま複製して書き換える(q := *p): 直るが、次に誰かが複製を忘れれば同じ穴。ロールごとの別ルート(/admin/upload): 上限の違いをパスで表すとクライアントの分岐が増えるロール×ルートの組み合わせが増えたら、ポリシーエンジン(OPA・Cedar)へ
上限なし天井を数字で書く(64 MiB)+負は起動時に拒む**NoBodyLimit bool**: 名前の向きは Day 066 の流儀に合うが、上限なしの口はディスクとメモリを枯らす入口になる。nginx の既定も 1m と有限本当にストリームで GB 級を受けるなら、本文上限ではなく読み取り速度・時間の上限(Day 061 のタイムアウト)で守る
ルート専用の予算外側の RateLimit 1 つ・別の財布(利用者+パターン)同じ財布で上限だけ変える(M4): 管理画面の利用で一般 API の予算が減る。内側に 2 つ目の RateLimit(B4): 二重に数え、積む順が 2 本になる管理画面の利用も全体の予算に含めたい(総量で守りたい)なら、全体の財布にも 1 回数える=2 つの財布に 1 回ずつ

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

  • 型で「できないこと」を作る: Go には継承もアノテーションも無いので、「グループから緩める指定を受け継げない」をコードレビューで守るのは難しい。GroupPolicy という小さな別の型を作るだけで、書けないことになります。Go の「小さい型を組み合わせる」流儀の使いどころです
  • 値型は「読むだけ」を言語が保証する: Go のポインタと値の選択は、性能より先に共有するかどうかの宣言です。実測で得が 40 B しか無いなら、値を選ぶ
  • ゼロ値の意味を 1 つにする: 0 が「既定」、-1 が「上限なし」のように同じフィールドに複数の番兵を持たせると、道具ごとの慣習(nginx の 0、MaxBytesReader の負=0)と衝突します
  • 入口を 1 つに: mux と表の 2 か所に書く処理を register 1 つにまとめる。2 か所に書く限り、いつか食い違う

🌐 他言語との比較

FWグループの既定を子が打ち消す方法今日との違い
Go net/http(今日)打ち消せない(緩める指定は継承しない)型で制限
chi(Go)Group は親の守りの列を複製して足すだけ(With、mux.go 249〜255 行)。外すには別の Group に分ける守りを足す(関数の列)。今日は値を足す
ASP.NET Coreコントローラの [Authorize] を、アクションの [AllowAnonymous] が打ち消す(手元知識・未確認)属性に「打ち消し」専用の型があるので、ゼロ値問題が起きない。代わりに打ち消しが grep で探しにくい場所に散らばる
Spring Security(Java)requestMatchers を上から順に評価し、最初に一致した規則を使う(手元知識・未確認)継承ではなく順序。/internal/** の permitAll を /internal/secrets より先に書くと開く——B1 と同じ形の穴が「行の順番」で起きる
nginxlocation ブロックごとに client_max_body_size、0 で検査しない(一次資料確認済み)0=無制限。今日のコードの 0=既定と逆

🏭 実務での使いどころ

  • コードレビューの観点: ポリシー・設定の構造体がポインタで持ち回されていたら、守りの中に代入文が無いかを grep する(\.Policy\.\w+ =、p\.\w+ =)
  • 「上限なし」を頼まれたとき: 数字の番兵(-1・0)で書かず、天井の値を相談する。nginx・Go・ALB などで 0 や負の意味が違うので、設定を移植したときに逆転する
  • グループ機能を足すとき: 何を継承させるかを、締める向き/緩める向きで分けて決める。緩める向きを継承させるなら、子が打ち消せる型(3 値)にする
  • 障害調査: 「特定の利用者が操作した後から、他の利用者の挙動が変わった」は共有状態の書き換え(B2)を疑う。T8 のように同時実行で揺れるなら、-race を付けて再現する

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
「ゼロ値を最も厳しくしたから、継承しても安全」単独のルートではうまくいったからゼロ値は「書いていない」と区別できないので、子が親の緩めを打ち消せない(B1)
「ctx から取り出した値はコピー」Day 066 では値型だったから型が *Policy なら共有の表そのもの。代入すると全員に効く(B2)
「-1 は上限なしの慣習」設定ファイルでよく見るnginx は 0、Go の MaxBytesReader は負を 0 として扱う。道具ごとに違う(B3)
「グループだけ守りを足すのは chi の流儀」Group+Use の例が多い積む順が 2 本になり、同じ状態を 2 回数える(B4)。値で表せるなら値で
「表に無いルートは Policy{}(最も厳しい)で十分」Day 066 の未登録パスの扱いRole は締める向きなので、Policy{} では admin ルートが開く。mux が知っているのに表に無いなら closed(B5)
「1 つ直したら別のテストが落ちた=直し方が悪い」落ちる場所が変わると不安になる隠れていた穴が見えるようになっただけのことがある(B5 が B1 を隠していた)

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

  • **実サーバー(httptest.NewServer)での確認**: 今日のバグはすべて守りの中の値の扱いで、通信路に依存しない。T8 の同時実行は in-process で確かめた
  • ルート専用の予算の継承で子が親より緩める場合(グループ Limit: 2 の中で子が Limit: 100): 今日の Group.Handle は子の値を優先するので緩められる。Limit を「小さいほうを取る」にするかは要件次第で、今日は決めていない
  • **管理グループ内で子が Public: true を書いた場合**: Auth は Public を Role より先に見るので公開になる。宣言表に Public: true と書かれる=grep で見つかるので、今日は「明示的な緩め」として許した
  • 起動時の検査(mux と表の突き合わせ): 標準の ServeMux には一覧の API が無い。chi の Routes() を使う版は書いていない
  • 時間の比較: 計測中の負荷が高く使っていない。割り当てだけを比べた
  • ASP.NET Core・Spring Security の実際の挙動: 手元知識のまま、ドキュメントは読んでいない

🚀 次のステップ

  • 発展: Group の入れ子(/admin/billing が /admin の中にある)を足し、Role が入れ子でどう重なるべきか(内側が上書き/両方を要求)を決めてテストにする
  • 発展: chi に移植し、Routes() で起動時に「全ルートに表の行があるか」を検査する。実行時の closed と起動時の検査で、どちらが何を捕まえるかを変異で比べる
  • 次回予告: 同テーマ(10/25 まで据え置き)。次は「概念理解」軸で、ミドルウェアの合成をデコレータ・関数合成・パイプラインの 3 つの見方で比べ、Go の func(http.Handler) http.Handler が他の形(next() を呼ぶ Express 型・(ctx, next) の Koa 型)と比べて何を表せて何を表せないかを実測する

🎯 自己評価

手順 1 で予想できたバグ(5 つのうち何個)

B5 が B1 を隠していたことに、直す前に気づけたか

手順 4 の 2 つの判断と採らなかった案

気づき・メモ