docs(06): add code review report
This commit is contained in:
@@ -1,9 +1,40 @@
|
||||
---
|
||||
phase: 06-http-routing-auth-groups-and-rate-limiting
|
||||
reviewed: 2026-09-19T19:31:09Z
|
||||
reviewed: 2026-09-20T11:44:42Z
|
||||
depth: standard
|
||||
files_reviewed: 48
|
||||
files_reviewed: 68
|
||||
files_reviewed_list:
|
||||
- ../fonoteka.go/config/http.yaml
|
||||
- ../fonoteka.go/docs/openapi.json
|
||||
- ../fonoteka.go/parity/fixtures/routes/GET__api_v1_fonoteka_genres_personal_token.yaml
|
||||
- ../fonoteka.go/parity/genre_security_test.go
|
||||
- ../fonoteka.go/parity/genres_seed_test.go
|
||||
- ../fonoteka.go/parity/manifest.yaml
|
||||
- ../fonoteka.go/parity/parity_contract_test.go
|
||||
- ../fonoteka.go/parity/parity_test.go
|
||||
- ../fonoteka.go/parity/php_debug_test.go
|
||||
- ../fonoteka.go/parity/php_parity.sh
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/postgres_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_coverage_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_coverage_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/models/api_token.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/plugin_boot_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes_bucket_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes_cors_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes_group_test.go
|
||||
- ../fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
|
||||
- ../fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- ../fonoteka.go/scripts/check-openapi.sh
|
||||
- ../fonoteka.go/scripts/swagger2openapi.go
|
||||
- bouncer/context.go
|
||||
- bouncer/context_test.go
|
||||
- bouncer/guard.go
|
||||
@@ -41,223 +72,145 @@ files_reviewed_list:
|
||||
- wire/response.go
|
||||
- wire/response_coverage_test.go
|
||||
- wire/response_test.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/config/http.yaml
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard_test.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/middleware/public_share_headers.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/plugin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/routes.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/user/plugin.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/routes_isolation_test.go
|
||||
- /media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/middleware/must_change_password.go
|
||||
findings:
|
||||
critical: 1
|
||||
warning: 7
|
||||
info: 4
|
||||
total: 12
|
||||
critical: 4
|
||||
warning: 11
|
||||
info: 0
|
||||
total: 15
|
||||
status: issues_found
|
||||
---
|
||||
|
||||
# Phase 6: Code Review Report
|
||||
|
||||
**Reviewed:** 2026-09-19T19:31:09Z
|
||||
**Reviewed:** 2026-09-20T11:44:42Z
|
||||
**Depth:** standard
|
||||
**Files Reviewed:** 48
|
||||
**Files Reviewed:** 68
|
||||
**Status:** issues_found
|
||||
|
||||
## Summary
|
||||
|
||||
Phase 6's framework seams (guard registry, dial-time SSRF check, trusted-proxy ClientIP, raw-group house-middleware refusal, path-scoped CORS) are mostly sound and match the written threat model. The live personal-token genres route is not. Middleware order inverts PHP's `throttle:fonoteka-api-token` → `inv.scope` pipeline, so invalid/missing bearers never enter the 60/min bucket — a rate-limit bypass on a shipped endpoint, and the exact failure 06-RESEARCH.md Pitfall 5 told the implementer to avoid. Several other fail-open paths (nil bucket Key, `Config.Int` → 0 body cap, bare-IP trusted proxies) are latent but real.
|
||||
The review found 15 defects, including four blockers. The limiter permits concurrent over-limit requests and lets anonymous callers choose a bucket through the untrusted Host header. The SSRF guard accepts transition-address encodings of private IPv4 targets, and the panic recovery can commit a successful partial response before appending the supposedly opaque 500 body. Eleven additional robustness and contract defects remain. Passing tests do not exercise these adversarial paths.
|
||||
|
||||
Verification run during review:
|
||||
|
||||
- `go test ./bouncer ./fetchguard ./surf ./wire -short` — pass
|
||||
- `go vet ./bouncer ./fetchguard ./surf ./wire ./internal/build` — pass
|
||||
- `go test ./plugins/golem15/fonoteka/... -short` — pass
|
||||
- `go test ./parity -short` — pass
|
||||
|
||||
## Narrative Findings (AI reviewer)
|
||||
|
||||
## Critical Issues
|
||||
|
||||
### CR-01: Personal-token throttle runs after InvScope, so unauthenticated requests are unlimited
|
||||
### CR-01: Check-then-increment is not atomic, so concurrent requests exceed the configured limit
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/routes.go:14-16`
|
||||
**Issue:** PHP attaches `throttle:fonoteka-api-token` on the **group** (`routes.php:450`) and `inv.scope:*` on the **route**. Laravel therefore rate-limits first; the bucket closure calls `guard('inv_token')->token()` itself and keys `tok:<id>` or `$request->ip()` even when the later scope gate 401s/403s.
|
||||
**Classification:** BLOCKER
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/limiter.go:95-115`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/limiter_store.go:73-100`
|
||||
**Issue:** Each request calls `TooManyAttempts` and `Hit` as two separately locked operations. If a key is at `Max-1`, multiple requests can all observe “not limited” before any of them calls `Hit`; every one is then admitted even though the count advances beyond `Max`. With `Max: 1`, two simultaneous first requests can both pass. This is a direct bypass of the security control under normal HTTP concurrency, and all tests exercise it sequentially.
|
||||
**Fix:** Make admission one atomic store operation. For example, replace the three-call protocol with `Attempt(key, max, decay) (allowed bool, attempts int, retryAfter time.Duration)` and perform expiry, threshold check, increment, and retry calculation under the same mutex/transaction. Add a barrier-based concurrent test in which many requests hit a `Max: 1` bucket and exactly one reaches the handler.
|
||||
|
||||
Go lists `inv_token`, `inv.scope:read`, then `throttle:fonoteka-api-token`. `surf.Router.wrap` applies names last-to-first, so the live order is:
|
||||
### CR-02: Inline throttles use the attacker-controlled Host header as part of the key
|
||||
|
||||
1. `inv_token` (CredentialGuard; on failure it does **not** write 401 — it passes through)
|
||||
2. `inv.scope:read` (401 `{"error":"Invalid token"}` and **returns**)
|
||||
3. `throttle:fonoteka-api-token` — **never reached** on a missing/invalid/wrong-prefix bearer
|
||||
**Classification:** BLOCKER
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/limiter.go:149-165`
|
||||
**Issue:** Anonymous inline throttles key on `r.Host + "|" + ClientIP(...)`. `Host` is request input, not a registered route domain. Unless every upstream rejects or rewrites arbitrary Host values, a caller can rotate `Host` on every request and obtain a fresh bucket, bypassing `throttle:10,1` and other inline limits. The onboarding and public-invitation groups are declared with inline throttles, so this becomes live as soon as their handlers land.
|
||||
**Fix:** Remove `r.Host` from the key. The inline throttle parameter already identifies the bucket, so use a stable server-side prefix such as `"inline:" + param + "|" + ClientIP(r, trusted)`. If host-scoped routes are introduced later, key from the trusted registered route metadata, never the incoming Host header. Add a test proving different Host headers from one IP share a bucket.
|
||||
|
||||
An attacker can spray `Authorization: Bearer inv_<garbage>` at `GET /api/v1/fonoteka/genres` with no 60/min cap. Prefixed tokens still pay a SHA-256 + indexed SQL lookup per hit (`token_guard.go:43-48`). PHP would 429 after 60/min per IP. 06-RESEARCH.md Pitfall 5 named this ordering requirement explicitly.
|
||||
### CR-03: The SSRF guard allows private IPv4 targets encoded through IPv6 transition prefixes
|
||||
|
||||
`fonoteka-api-token`'s Key already does the right fallback (`tok:<id>` else `ClientIP`) **if it runs**. It just never runs on the deny path.
|
||||
**Classification:** BLOCKER
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/fetchguard/ip.go:5-45`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/fetchguard/fetch.go:145-163`
|
||||
**Issue:** `Unmap` only handles IPv4-mapped `::ffff:x.x.x.x`. The allow decision treats all other global-looking IPv6 addresses as public. On networks supporting the corresponding transition mechanism, an attacker-controlled DNS record can therefore target loopback, RFC1918, or metadata addresses via values such as `64:ff9b::7f00:1` (NAT64 loopback), `64:ff9b::a9fe:a9fe` (NAT64 metadata), or `2002:7f00:1::` (6to4 loopback). The helper is explicitly intended as an SSRF boundary, so an environment-dependent route to private infrastructure is still a vulnerability.
|
||||
**Fix:** Classify IPv6 transition prefixes before dialing. Extract and re-check embedded IPv4 for `64:ff9b::/96`, the local-use NAT64 prefix `64:ff9b:1::/48`, and 6to4 `2002::/16`; reject other transition forms that cannot be safely decoded. Add table tests for embedded loopback, RFC1918, link-local metadata, and public IPv4 values.
|
||||
|
||||
**Fix:** Keep `inv_token` **before** throttle (so `tok:<id>` is available) and `inv.scope` **after** throttle (so 401/403 still consume the budget):
|
||||
### CR-04: Panic recovery can return 200 and leak a partial response instead of the opaque 500
|
||||
|
||||
```go
|
||||
r.Group("/api/v1/fonoteka", surf.Use("inv_token", "throttle:fonoteka-api-token", "inv.scope:read"), func(g pact.Router) {
|
||||
g.Get("/genres", handler)
|
||||
})
|
||||
```
|
||||
|
||||
Add a test that 61 unauthenticated requests to the assembled genres token route yield a 429 with `{"message":"Too Many Attempts."}`. Do **not** put throttle first: that would key every request by IP and break `tok:<id>`.
|
||||
**Classification:** BLOCKER
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/router.go:520-541`
|
||||
**Issue:** Both recovery wrappers write their 500 only after recovering. If a handler or middleware has already called `Write`/`WriteHeader`, the status and bytes are committed; the later `WriteHeader(500)` is ignored. A non-raw handler that writes sensitive partial JSON and then panics will return the earlier status (often 200) with the partial bytes followed by the opaque error JSON. The tests only panic before any write, so they do not cover this broken error boundary.
|
||||
**Fix:** Buffer non-streaming house responses until the route returns successfully, then commit them. On panic, discard the buffered status/headers/body and write only the opaque 500. Raw routes need an explicit policy: either buffer them too or document that they cannot promise a clean recovery after a committed write. Add partial-write-then-panic tests for both raw and house routes.
|
||||
|
||||
## Warnings
|
||||
|
||||
### WR-01: TokenGuard stamps last_used_ip from RemoteAddr, ignoring trusted proxies
|
||||
### WR-01: Invalid bucket definitions and missing stores silently disable rate limiting
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go:79-88`
|
||||
**Issue:** PHP `ApiTokenGuard` writes `'last_used_ip' => $this->request->ip()`. D-04 requires `surf.ClientIP` as the single source for limiter keys **and later logging**. `remoteIP` splits `RemoteAddr` only. Behind nginx/Nitro every token's `last_used_ip` becomes the proxy, which is both a parity miss and a useless audit column.
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/limiter.go:63-79`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/limiter.go:84-94`
|
||||
**Issue:** `RegisterBucket` accepts `Key == nil`, `Max <= 0`, and `Decay <= 0`. At request time a nil key, nil store, or resolution error simply calls the protected handler. A nil key therefore disables the control, and a non-positive decay continually expires the entry so requests remain effectively unlimited. These are boot-time configuration errors and should not produce a fail-open live route.
|
||||
**Fix:** Reject nil `Key`, `Max < 1`, `Decay <= 0`, and nil stores during construction/registration. If an impossible runtime error remains, return an opaque 500 rather than calling `next`.
|
||||
|
||||
**Fix:**
|
||||
### WR-02: Missing or malformed body-limit configuration turns the limit off
|
||||
|
||||
```go
|
||||
func (g *TokenGuard) AuthenticateCredential(r *http.Request) (*bouncer.Principal, any, error) {
|
||||
// ...
|
||||
ip := surf.ClientIP(r, surf.TrustedProxies(/* plugin config, same list the buckets close over */))
|
||||
```
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/router.go:432-441`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/bodylimit.go:29-45`
|
||||
**Issue:** `Config.Int` yields zero for absent and non-coercible values. `BuildRouter` copies that zero to `defaultBytes`, and `wrap` omits `MaxBytesReader` whenever the value is not positive. A missing `http.yaml`, typo, or malformed value therefore changes the advertised default from 128 MiB to unlimited without a boot error.
|
||||
**Fix:** Read the raw value with presence/type checks and fail boot unless `default_bytes` is a positive integer. Keep “unlimited” available only through the explicit raw-route exemption.
|
||||
|
||||
Thread the trusted-prefix list into `NewTokenGuard` (or read it from the same config the plugin already loads in `Buckets()`). Do not invent a second XFF parser.
|
||||
### WR-03: Trusted-proxy parsing silently drops valid operational forms and invalid entries
|
||||
|
||||
### WR-02: FixedWindowLimiter fail-opens when Key is nil, store is nil, or resolve failed
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/clientip.go:49-82`
|
||||
**Issue:** `TrustedProxies` only accepts CIDR strings and silently skips everything else. A common deployment value such as `127.0.0.1` is discarded because it is not written as `/32`; a typo is discarded the same way. Behind a reverse proxy this collapses all users onto the proxy address and corrupts every IP-keyed limiter budget, with no startup signal.
|
||||
**Fix:** Accept a bare `netip.Addr` by converting it to `/32` or `/128`, return an error for unparseable entries, and propagate that error from `BuildRouter` so the application fails boot.
|
||||
|
||||
**File:** `surf/limiter.go:84-94`
|
||||
**Issue:** `Middleware` captures `resolve` errors into the request closure, then **skips limiting**:
|
||||
### WR-04: Token last-used auditing ignores the framework's trusted-proxy client IP
|
||||
|
||||
```go
|
||||
if err != nil || b.Key == nil || l.store == nil {
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
```
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go:50-60`; `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/classes/auth/token_guard.go:79-88`
|
||||
**Issue:** `last_used_ip` is derived directly from `RemoteAddr`, while rate-limit keys use `surf.ClientIP`. Behind a configured trusted proxy the same request is attributed to the real client for limiting but to the proxy for audit history, making the security audit field misleading.
|
||||
**Fix:** Parse trusted proxies once during plugin boot and inject them into `TokenGuard`; use `surf.ClientIP(r, trusted)` for the audit stamp so there is one client-IP policy.
|
||||
|
||||
`ValidateThrottle` at Assemble time covers unknown/malformed `throttle:` names, but `RegisterBucket` never requires `Key != nil` or `Max >= 1`. A plugin that registers `fonoteka-api-token` with a nil Key boots cleanly and then never rate-limits. `l == nil` is the same no-op. Fail-open is the wrong default for a security control.
|
||||
### WR-05: Retry-After is rounded down and can tell clients to retry before the window expires
|
||||
|
||||
**Fix:** Reject nil Key / non-positive Max in `RegisterBucket`. In `Middleware`, if `err != nil || b.Key == nil || l.store == nil`, do not call `next` — `ValidateThrottle` should have made this unreachable; if it is reached, 500/fail closed:
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/limiter.go:96-103`
|
||||
**Issue:** Integer duration division floors the remaining time. A request with 900 ms left receives `Retry-After: 0`; a nearly fresh one-minute window normally reports 59 instead of 60. Compliant clients can immediately retry and receive another 429.
|
||||
**Fix:** Round up to whole seconds and clamp live windows to at least one second, e.g. `(retryAfter + time.Second - 1) / time.Second`.
|
||||
|
||||
```go
|
||||
if err != nil || b.Key == nil || l.store == nil {
|
||||
http.Error(w, `{"error":true,"message":"Internal server error"}`, http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
```
|
||||
### WR-06: Middleware factories are invoked twice during normal Assemble
|
||||
|
||||
### WR-03: Body-limit middleware never maps MaxBytesError to 413; upload_bytes is dead
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/router.go:336-348`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/router.go:487-492`
|
||||
**Issue:** `BuildRouter` calls `wrap` once per route as validation, and `Assemble` immediately calls `compile`, which calls `wrap` again. Each `factory.fn(param)` therefore runs twice. Plugin factories are an extension API and may allocate resources, register metrics, or bind single-use state; the first constructed chain is discarded. A factory returning nil also panics rather than producing a contextual boot error.
|
||||
**Fix:** Split pure route validation from middleware construction, or compile once and retain the compiled handler. Validate factory and middleware return values and turn nil into a plugin/name-specific boot error. Add a factory call-count test around `Assemble`.
|
||||
|
||||
**File:** `surf/bodylimit.go:10-18`; `surf/router.go:426-440`; `surf/bodylimit_test.go:105-116`
|
||||
**Issue:** `bodyLimit` only wraps `http.MaxBytesReader`. Recent Go does **not** write 413 for you; the reader returns `*http.MaxBytesError` to the handler. `TestBodyLimitDefaultRejectsOversizedBody` gets 413 only because the **test plugin** checks `errors.As(err, &maxErr)` and writes 413 itself. House handlers that `json.NewDecoder(r.Body).Decode` (or `io.ReadAll`) will 500, or worse, hang until recoverJSON.
|
||||
### WR-07: The generated OpenAPI document omits a live route and all authentication requirements
|
||||
|
||||
Separately, `r.uploadBytes` is loaded from `http.body_limits.upload_bytes` and never read. D-18 promised a larger cap for upload routes; the only override is a manual `body.limit:N` string, and no production route uses it. Today both YAML values are 128MiB so the miss is latent — the next upload route will not pick up `upload_bytes` automatically.
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go:45-52`; `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/docs/openapi.json:40-73`
|
||||
**Issue:** The same handler is live at both `/_fonoteka/api/v1/genres` and `/api/v1/fonoteka/genres`, but only the JWT path is documented. The documented operation also has no bearer security scheme or operation security requirement, so generated clients see a public endpoint and cannot discover the personal-token surface.
|
||||
**Fix:** Add both route annotations, define the appropriate bearer schemes, annotate each operation's security requirement, document its 401/403/422/423 responses, and regenerate `docs/openapi.json`.
|
||||
|
||||
**Fix:** Convert the error in the middleware (or a small inner wrapper that reads nothing, only intercepts `MaxBytesError` from `next`), and either apply `uploadBytes` to a named group/flag or delete the field until a real upload route exists:
|
||||
### WR-08: The Swagger-to-OpenAPI converter emits invalid OpenAPI for supported Swagger constructs
|
||||
|
||||
```go
|
||||
func bodyLimit(n int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if n > 0 && r.Body != nil {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, n)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/scripts/swagger2openapi.go:99-134`; `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/scripts/swagger2openapi.go:136-209`
|
||||
**Issue:** Path-item `parameters` are copied unchanged instead of being converted; `formData` parameters are also left as OpenAPI 2 parameters rather than combined into an OpenAPI 3 `requestBody`. Top-level `produces`/`consumes` are ignored. These are standard Swagger 2 constructs, especially for the upload routes anticipated by this phase, and will either make the validation step fail or silently assign the wrong media type.
|
||||
**Fix:** Carry document-level media types into path/operation conversion, run path-level parameters through the same conversion, and translate body/form-data parameters into request bodies (including multipart schemas). Add table-driven converter tests with path parameters, JSON bodies, multipart uploads, and non-JSON global media types.
|
||||
|
||||
is not enough. Wrap `next` with a handler that, if `next` surfaces `*http.MaxBytesError` (via a capturing ResponseWriter, or by documenting that `wire` helpers check it), writes 413 with a stable JSON body. At minimum, change the test so it does **not** convert the error — the framework must.
|
||||
### WR-09: CORS can generate the forbidden wildcard-origin plus credentials combination
|
||||
|
||||
### WR-04: TrustedProxies silently drops bare IPs (PHP accepts `127.0.0.1`)
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/cors.go:69-95`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/surf/cors.go:104-119`
|
||||
**Issue:** When `allowed_origins` contains `*`, `corsAllowOrigin` always returns `*`; `SupportsCredentials` independently adds `Access-Control-Allow-Credentials: true`. Browsers reject that combination. The checked-in config currently disables credentials, but a valid-looking future config change silently produces a non-functional policy.
|
||||
**Fix:** Fail boot on wildcard plus credentials, or reflect a validated request Origin and emit `Vary: Origin` when credentials are enabled.
|
||||
|
||||
**File:** `surf/clientip.go:75-82`
|
||||
**Issue:** `netip.ParsePrefix("127.0.0.1")` fails. Malformed entries `continue` with no error. PHP `TRUSTED_PROXIES=127.0.0.1` (the production `.env` value that fixed CR-01 / Nitro collapsing public-IP buckets) is therefore a silent no-op if an operator copies it into `http.trusted_proxies`. The list becomes empty → `RemoteAddr` only → every visitor behind loopback Nitro/nginx shares one limiter key. That is the exact outage the PHP comment in `config/app.php:141-153` exists to prevent.
|
||||
### WR-10: An unknown Fetch policy mode fails open to PublicOnlyMode
|
||||
|
||||
**Fix:** Accept a bare address as `/32` or `/128`, and fail boot on unparseable entries instead of skipping:
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/fetchguard/fetch.go:41-51`; `/media/nvme/dev/golem15/summercms.io/summercms/summercms.go/fetchguard/policy.go:14-19`
|
||||
**Issue:** The host allow-list runs only when `policy.Mode == AllowHostsMode`; every other integer, including invalid values, behaves as `PublicOnlyMode`. A bad cast/configuration can therefore broaden a host allow-list to every public host.
|
||||
**Fix:** Switch explicitly on the two defined modes and return a typed configuration error in the default case. Add an invalid-mode test that performs no network I/O.
|
||||
|
||||
```go
|
||||
p, err := netip.ParsePrefix(e)
|
||||
if err != nil {
|
||||
if addr, aerr := netip.ParseAddr(e); aerr == nil {
|
||||
p = netip.PrefixFrom(addr, addr.BitLen())
|
||||
} else {
|
||||
return nil, fmt.Errorf("surf: http.trusted_proxies: invalid CIDR %q", e)
|
||||
}
|
||||
}
|
||||
```
|
||||
### WR-11: InvScope's wire body does not match the project's no-newline JSON convention
|
||||
|
||||
`TrustedProxies` currently returns only `[]netip.Prefix`; returning `error` from BuildRouter is the fail-boot path the rest of this phase already uses.
|
||||
|
||||
### WR-05: fetchguard private table misses NAT64-encoded loopback/RFC1918/metadata
|
||||
|
||||
**File:** `fetchguard/ip.go:5-45`; `fetchguard/fetch.go:145-163`
|
||||
**Issue:** Dial-time `Unmap()` closes `::ffff:169.254.169.254`. It does **not** close well-known encapsulations PHP also misses, and CONTEXT said Go may be stricter, never looser:
|
||||
|
||||
- `64:ff9b::7f00:1` → 127.0.0.1 (NAT64)
|
||||
- `64:ff9b::a9fe:a9fe` → 169.254.169.254 (metadata via NAT64)
|
||||
- `2002:7f00:1::` → 127.0.0.1 (6to4)
|
||||
|
||||
`isReservedOrPrivate` only walks PHP's v4 CIDRs / v6 `::1`, `fe80::/10`, `fc00::/7`. No production caller this phase, but this is the helper Phase 12/14 will call for user-supplied cover URLs.
|
||||
|
||||
**Fix:** After `Unmap()`, also reject `64:ff9b::/96` and `2002::/16`, and if the address is in those prefixes, classify the embedded v4 with the existing v4 table:
|
||||
|
||||
```go
|
||||
if nat64 := netip.MustParsePrefix("64:ff9b::/96"); nat64.Contains(addr) {
|
||||
// extract the trailing 32 bits as IPv4 and re-run the v4 table
|
||||
}
|
||||
```
|
||||
|
||||
Keep the PHP CIDR table as the v4/v6 baseline; add the encapsulations as the documented Go-stricter layer.
|
||||
|
||||
### WR-06: Retry-After truncates toward zero
|
||||
|
||||
**File:** `surf/limiter.go:97-100`
|
||||
**Issue:** `secs := int(retryAfter / time.Second)` floors. A window with 900ms left sends `Retry-After: 0`, which clients treat as "retry immediately" and stampede the same key. Laravel's `ThrottleRequests` ceils the remaining seconds.
|
||||
|
||||
**Fix:**
|
||||
|
||||
```go
|
||||
secs := int((retryAfter + time.Second - 1) / time.Second)
|
||||
if secs < 1 {
|
||||
secs = 1
|
||||
}
|
||||
```
|
||||
|
||||
### WR-07: Missing body-limit config fail-opens to unlimited
|
||||
|
||||
**File:** `surf/router.go:438-441`
|
||||
**Issue:** `app.Config.Int("http.body_limits.default_bytes")` returns **0** when the key is absent (`compass/config.go:131-136`). `routeBodyLimit` then returns 0, and `bodyLimit` is not installed. Assemble with a nil/partial config (or a forgotten `http.yaml`) accepts unbounded POST bodies. Fetch defaults explicitly error on non-positive values (D-14); body limits do not.
|
||||
|
||||
**Fix:** If `default_bytes` is missing or `<= 0` at BuildRouter, fail boot (or apply the operator-confirmed 134217728 and log). Do not treat 0 as "unlimited" except on raw groups, where that exemption is already explicit.
|
||||
|
||||
## Info
|
||||
|
||||
### IN-01: InvScope 401/403 bodies have a trailing newline
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go:31-35`
|
||||
**Issue:** `json.NewEncoder(w).Encode` appends `\n`. Tests hide it with `strings.TrimSpace`. `wire.WriteJSON` exists specifically to match PHP `json_encode` (no trailing newline). JWT `write401` has the same encoder habit (pre-Phase-6), but new token-group bodies should use `wire.WriteJSON`.
|
||||
|
||||
**Fix:** `wire.WriteJSON(w, status, v)`.
|
||||
|
||||
### IN-02: `bouncer.Middleware` and `jwtGuard` duplicate the same chain
|
||||
|
||||
**File:** `bouncer/jwt.go:29-64` and `83-107`
|
||||
**Issue:** `NewJWTGuard` copies `Middleware`'s body. Nothing in the app still calls `bouncer.Middleware(` — the user plugin derives `jwt.auth` from the registry. Two copies will drift (already almost: both are currently identical).
|
||||
|
||||
**Fix:** Make `Middleware` a thin wrapper over `NewJWTGuard` + `Registry`, or delete the old func once callers are gone.
|
||||
|
||||
### IN-03: CORS will emit the illegal `ACAO: *` + `Allow-Credentials: true` combo
|
||||
|
||||
**File:** `surf/cors.go:105-107` and `92-94`
|
||||
**Issue:** `corsAllowOrigin` returns `"*"` whenever the origin list contains `*`, then `SupportsCredentials` unconditionally adds `true`. Browsers reject that. Current `http.yaml` has `supports_credentials: false`, so production is fine; a future config change would silently ship a non-functional CORS policy.
|
||||
|
||||
**Fix:** If credentials are on, reflect the request Origin (never `*`), or fail boot when both `*` and credentials are set.
|
||||
|
||||
### IN-04: ListGenres OpenAPI annotation only names the JWT path
|
||||
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/controllers/genre_controller.go:52`
|
||||
**Issue:** `@Router /_fonoteka/api/v1/genres [get]` omits `GET /api/v1/fonoteka/genres`, the other live mount of the same handler. HTTP-08's generated spec is incomplete for the token surface.
|
||||
|
||||
**Fix:** Add a second `@Router /api/v1/fonoteka/genres [get]` (and regenerate).
|
||||
**Classification:** WARNING
|
||||
**File:** `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope.go:31-35`; `/media/nvme/dev/golem15/summercms.io/summercms/fonoteka.go/plugins/golem15/fonoteka/middleware/token_scope_test.go:71-76`
|
||||
**Issue:** `json.Encoder.Encode` appends a newline, while `wire.WriteJSON` exists specifically to reproduce PHP `json_encode` without one. The tests call `TrimSpace`, hiding the byte-level mismatch for both 401 and 403 responses.
|
||||
**Fix:** Replace the local writer with `wire.WriteJSON` and assert exact response bytes in the tests without trimming.
|
||||
|
||||
---
|
||||
|
||||
_Reviewed: 2026-09-19T19:31:09Z_
|
||||
_Reviewer: Claude (gsd-code-reviewer)_
|
||||
_Reviewed: 2026-09-20T11:44:42Z_
|
||||
_Reviewer: the agent (gsd-code-reviewer)_
|
||||
_Depth: standard_
|
||||
|
||||
Reference in New Issue
Block a user