Skip to content

Commit 75471bc

Browse files
committed
docs(auth, avatar): fix misleading and stale docstrings around the security fix
Sweep over the docstrings touched (or adjacent to) PR #290's security work, prompted by Copilot's post-merge review of withSecurityHeaders and an adversarial pass from Codex on the rest of the same surface. All changes are docstring/comment-only; no code, no behavior, no test churn. * withSecurityHeaders CONSUMER NOTE (auth.go, v2/auth.go) — the previous text told consumers HTML custom handlers could fix CSP blocking by "moving scripts/styles to external files served from 'self'", but the wrapper applies default-src 'none' and sandbox, so even self-hosted resources are blocked. New text spells out what the wrapper actually does and gives a concrete relaxed-CSP example. The example list also drops "dev_provider's login page" — that page is served by DevAuthServer on its own HTTP listener, not by handlers Service.Handlers wraps. Replaced with "custom server login pages". * Proxy.Put godoc — was "stores retrieved avatar to avatar.Store. Gets image from user info. Returns proxied url", which omitted the identicon fallback that fires on empty u.Picture, fetch failure, or non-image upstream bytes. Doc now describes that the function silently substitutes an identicon in those cases and returns its proxied URL — the caller is not told the upstream was rejected. * Proxy.Handler godoc — was "returns token routes for given provider", a leftover from a much older shape of the code. Replaced with a description of what Handler actually does today: serves stored avatar bytes by id, sniffs against an allowlist, sets defense headers. * Handler's inline serve-time validation comment — said "validate the bytes really are an image", but Handler reads up to sniffLen bytes and runs them through http.DetectContentType + an allowlist. That is content-type sniffing, not proof of full decodability. Reworded to match. * Proxy.resize godoc — said "validates that the input is a real image", but the no-resize path returns the original bytes after only image.DecodeConfig and the dimension cap; no full decode runs. Split into format/dimension checks (DecodeConfig only) vs full decode (resize path only). * maxAvatarFetchSize constant — mentioned only the remote-URL fetch path; after #290 the cap also bounds PutContent's caller-supplied reader and is the implicit size invariant resize trusts. Doc updated to name both callers. All changes mirrored across v1 and v2.
1 parent b19c8d7 commit 75471bc

4 files changed

Lines changed: 74 additions & 130 deletions

File tree

auth.go

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -239,30 +239,17 @@ func (s *Service) Handlers() (authHandler, avatarHandler http.Handler) {
239239
return withSecurityHeaders(http.HandlerFunc(ah)), withSecurityHeaders(http.HandlerFunc(s.avatarProxy.Handler))
240240
}
241241

242-
// withSecurityHeaders wraps an auth response handler to apply strict CSP and nosniff
243-
// on every response. The go-pkgz/auth package's own response surface is JSON-only
244-
// for auth routes and images for the avatar route — no built-in HTML rendering
245-
// anywhere — so this CSP is unconditionally safe and gives the auth origin
246-
// defense-in-depth against any future trust-boundary regression that might emit a
247-
// renderable body.
242+
// withSecurityHeaders wraps a handler to set Content-Security-Policy
243+
// "default-src 'none'; sandbox; frame-ancestors 'none'" and X-Content-Type-Options
244+
// "nosniff" on every response. Safe to apply unconditionally because go-pkgz/auth
245+
// only emits JSON (auth routes) and images (avatar route).
248246
//
249-
// - Content-Security-Policy: default-src 'none'; sandbox — blocks inline scripts
250-
// and event handlers even if a body is ever served as HTML by mistake; the
251-
// sandbox directive additionally isolates any rendered document from this origin.
252-
// - X-Content-Type-Options: nosniff — prevents browsers from MIME-overriding the
253-
// declared Content-Type to a more dangerous one.
254-
//
255-
// The avatar Handler additionally sets Content-Disposition: inline; filename="avatar"
256-
// inside itself, so direct callers (tests, custom mounts) still get the full header
257-
// set without going through this wrapper.
258-
//
259-
// CONSUMER NOTE: custom providers added via Service.AddCustomHandler / AddProvider
260-
// are also wrapped. If a custom provider renders HTML (login forms, JS-based flows,
261-
// the dev_provider's login page, etc.), the strict CSP will block inline scripts and
262-
// event handlers on those pages. Such providers should either (a) override the CSP
263-
// for their own response by calling w.Header().Set("Content-Security-Policy", ...)
264-
// before writing — Set replaces the wrapper's value — or (b) move any required
265-
// scripts/styles to external files served from 'self'.
247+
// CONSUMER NOTE: custom providers registered through AddCustomHandler / AddProvider
248+
// are wrapped too. A provider that renders HTML (login form, JS flow, custom server
249+
// login page, etc.) will be blocked by this CSP — default-src 'none' plus sandbox
250+
// stop scripts, styles, forms, and images even when served from 'self'. Such
251+
// providers must override the CSP on their own response (call w.Header().Set before
252+
// writing — Set replaces the wrapper's value) and relax only the directives needed.
266253
func withSecurityHeaders(next http.Handler) http.Handler {
267254
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
268255
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'")

avatar/avatar.go

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,12 @@ import (
2828
// http.sniffLen is 512 bytes which is how much we need to read to detect content type
2929
const sniffLen = 512
3030

31-
// maxAvatarFetchSize bounds the bytes read from a remote avatar URL. 10 MiB is
32-
// generous for any reasonable avatar (Telegram caps photo at 5 MiB; Gravatar is
33-
// much smaller); the cap protects Proxy.Put against an upstream sending an
34-
// unbounded body that would exhaust process memory inside resize.
31+
// maxAvatarFetchSize caps the byte length of any avatar accepted by load,
32+
// PutContent, or resize — i.e. the upper bound on bytes that ever reach Store.Put.
3533
const maxAvatarFetchSize = 10 << 20
3634

37-
// maxAvatarPixels caps the declared pixel count of an avatar before any raster
38-
// decode is allowed. Without this, a tiny compressed "decompression bomb" image
39-
// declaring e.g. 65535x65535 px would force image.Decode to allocate gigabytes
40-
// of pixel memory and OOM the auth service on a single login attempt. 16 MP
41-
// covers any realistic avatar (~4096x4096) while keeping peak allocation bounded.
35+
// maxAvatarPixels caps cfg.Width*cfg.Height (from image.DecodeConfig) before any
36+
// full image.Decode runs. Defends against decompression-bomb inputs.
4237
const maxAvatarPixels = 16 * 1024 * 1024
4338

4439
// Proxy provides http handler for avatars from avatar.Store
@@ -51,7 +46,11 @@ type Proxy struct {
5146
ResizeLimit int
5247
}
5348

54-
// Put stores retrieved avatar to avatar.Store. Gets image from user info. Returns proxied url
49+
// Put fetches u.Picture, validates and optionally resizes the body, stores it via
50+
// Store, and returns the proxied avatar URL. If u.Picture is empty, the fetch fails,
51+
// or the upstream bytes are not a recognized image format within the configured
52+
// dimension and size limits, Put silently falls back to a generated identicon and
53+
// returns its proxied URL — the caller is not told upstream was rejected.
5554
func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err error) {
5655

5756
genIdenticon := func(userID string) (avatarURL string, err error) {
@@ -168,7 +167,9 @@ func (p *Proxy) load(url string, client *http.Client) ([]byte, error) {
168167
return body, nil
169168
}
170169

171-
// Handler returns token routes for given provider
170+
// Handler serves stored avatar content by avatar id. GET only; rejects invalid ids
171+
// (403) and stored bytes that fail the safeImgContentType sniff (415). Layered
172+
// defense headers are set on every response via setAvatarDefenseHeaders.
172173
func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
173174
setAvatarDefenseHeaders(w)
174175

@@ -195,11 +196,9 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
195196
}
196197
}()
197198

198-
// io.ReadFull keeps reading until the buffer is full or EOF, so a Store
199-
// implementation that returns a buffered reader with a small first-Read size
200-
// won't cause DetectContentType to misclassify a real image. Short bodies
201-
// (avatars under sniffLen) return ErrUnexpectedEOF — that's expected, we sniff
202-
// what we got.
199+
// ReadFull (not Read) so a Store backend that hands back a short first Read
200+
// doesn't truncate the sniff window. Short bodies are signaled by
201+
// ErrUnexpectedEOF and treated like EOF.
203202
buf := make([]byte, sniffLen)
204203
n, err := io.ReadFull(avReader, buf)
205204
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
@@ -208,11 +207,9 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
208207
return
209208
}
210209

211-
// validate the bytes really are an image before declaring a content type. Even
212-
// though Put() now refuses to store non-image content, this catches stores poisoned
213-
// before the fix and any future regression — never trust the bytes at the store
214-
// boundary alone, validate again at serve time. An empty body (e.g. NoOp store)
215-
// is treated as a benign no-content case: nothing to render, no XSS surface.
210+
// sniff buf[:n] against the safeImgContentType allowlist. This is not a full
211+
// image decode; it catches stores poisoned before Put validated. An empty body
212+
// (e.g. NoOp store) is treated as benign — nothing to render.
216213
var contentType string
217214
if n > 0 {
218215
var ctErr error
@@ -248,20 +245,12 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
248245
}
249246
}
250247

251-
// resize validates that the input is a real image and, if needed, re-encodes it to
252-
// fit within "limit" px on the larger side preserving aspect ratio. Returns nil for
253-
// non-image content or for declared dimensions exceeding maxAvatarPixels so attacker
254-
// payloads (HTML/SVG/decompression bombs) never reach the store. With limit <= 0 or
255-
// when the image already fits, the original bytes are returned verbatim so animated
256-
// GIFs and other multi-frame formats round-trip without being flattened to one frame.
257-
//
258-
// Validation uses image.DecodeConfig (cheap — declares dimensions, allocates nothing)
259-
// before any full image.Decode, so a 100 KB compressed image declaring 65535x65535 px
260-
// is rejected without ever materializing the raster.
261-
//
262-
// Callers (load, PutContent, identicon generation) must ensure body is bounded by
263-
// maxAvatarFetchSize before calling; resize trusts the size invariant rather than
264-
// re-buffering. An empty body or a body over the cap is refused defensively.
248+
// resize checks body via image.DecodeConfig (format + dimensions only, no raster
249+
// allocation), then either returns the original bytes verbatim (limit <= 0, or
250+
// dimensions already within limit) or fully decodes and re-encodes to PNG fitting
251+
// within limit px on the larger side. Returns nil for non-image input or for
252+
// dimensions exceeding maxAvatarPixels. Caller must ensure body is within
253+
// maxAvatarFetchSize; a body over the cap is also refused defensively.
265254
func (p *Proxy) resize(body []byte, limit int) io.Reader {
266255
if len(body) == 0 || int64(len(body)) > maxAvatarFetchSize {
267256
p.Logf("[WARN] avatar resize(): refusing body of size %d (cap %d)", len(body), maxAvatarFetchSize)
@@ -315,13 +304,9 @@ func (p *Proxy) resize(body []byte, limit int) io.Reader {
315304
return &out
316305
}
317306

318-
// safeImgContentType returns the sniffed content type if the bytes look like a safe
319-
// raster image format. The set is an explicit allowlist (PNG, JPEG, GIF, WebP, BMP,
320-
// ICO) — no HasPrefix("image/") catch-all — so future scriptable image/* MIME types
321-
// added by http.DetectContentType cannot silently pass. Returns an error otherwise.
322-
// image/svg+xml is excluded because SVG can execute scripts when navigated to top
323-
// level; image/* coverage of icon files uses both spellings http.DetectContentType
324-
// is known to return.
307+
// safeImgContentType returns the sniffed Content-Type for img if it is one of the
308+
// allow-listed raster image formats (PNG, JPEG, GIF, WebP, BMP, ICO under both
309+
// MIME spellings http.DetectContentType uses), else an error. SVG is excluded.
325310
func safeImgContentType(img []byte) (string, error) {
326311
ct := http.DetectContentType(img)
327312
base := ct

v2/auth.go

Lines changed: 10 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -240,30 +240,17 @@ func (s *Service) Handlers() (authHandler, avatarHandler http.Handler) {
240240
return withSecurityHeaders(http.HandlerFunc(ah)), withSecurityHeaders(http.HandlerFunc(s.avatarProxy.Handler))
241241
}
242242

243-
// withSecurityHeaders wraps an auth response handler to apply strict CSP and nosniff
244-
// on every response. The go-pkgz/auth package's own response surface is JSON-only
245-
// for auth routes and images for the avatar route — no built-in HTML rendering
246-
// anywhere — so this CSP is unconditionally safe and gives the auth origin
247-
// defense-in-depth against any future trust-boundary regression that might emit a
248-
// renderable body.
243+
// withSecurityHeaders wraps a handler to set Content-Security-Policy
244+
// "default-src 'none'; sandbox; frame-ancestors 'none'" and X-Content-Type-Options
245+
// "nosniff" on every response. Safe to apply unconditionally because go-pkgz/auth
246+
// only emits JSON (auth routes) and images (avatar route).
249247
//
250-
// - Content-Security-Policy: default-src 'none'; sandbox — blocks inline scripts
251-
// and event handlers even if a body is ever served as HTML by mistake; the
252-
// sandbox directive additionally isolates any rendered document from this origin.
253-
// - X-Content-Type-Options: nosniff — prevents browsers from MIME-overriding the
254-
// declared Content-Type to a more dangerous one.
255-
//
256-
// The avatar Handler additionally sets Content-Disposition: inline; filename="avatar"
257-
// inside itself, so direct callers (tests, custom mounts) still get the full header
258-
// set without going through this wrapper.
259-
//
260-
// CONSUMER NOTE: custom providers added via Service.AddCustomHandler / AddProvider
261-
// are also wrapped. If a custom provider renders HTML (login forms, JS-based flows,
262-
// the dev_provider's login page, etc.), the strict CSP will block inline scripts and
263-
// event handlers on those pages. Such providers should either (a) override the CSP
264-
// for their own response by calling w.Header().Set("Content-Security-Policy", ...)
265-
// before writing — Set replaces the wrapper's value — or (b) move any required
266-
// scripts/styles to external files served from 'self'.
248+
// CONSUMER NOTE: custom providers registered through AddCustomHandler / AddProvider
249+
// are wrapped too. A provider that renders HTML (login form, JS flow, custom server
250+
// login page, etc.) will be blocked by this CSP — default-src 'none' plus sandbox
251+
// stop scripts, styles, forms, and images even when served from 'self'. Such
252+
// providers must override the CSP on their own response (call w.Header().Set before
253+
// writing — Set replaces the wrapper's value) and relax only the directives needed.
267254
func withSecurityHeaders(next http.Handler) http.Handler {
268255
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
269256
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'")

v2/avatar/avatar.go

Lines changed: 27 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -28,17 +28,12 @@ import (
2828
// http.sniffLen is 512 bytes which is how much we need to read to detect content type
2929
const sniffLen = 512
3030

31-
// maxAvatarFetchSize bounds the bytes read from a remote avatar URL. 10 MiB is
32-
// generous for any reasonable avatar (Telegram caps photo at 5 MiB; Gravatar is
33-
// much smaller); the cap protects Proxy.Put against an upstream sending an
34-
// unbounded body that would exhaust process memory inside resize.
31+
// maxAvatarFetchSize caps the byte length of any avatar accepted by load,
32+
// PutContent, or resize — i.e. the upper bound on bytes that ever reach Store.Put.
3533
const maxAvatarFetchSize = 10 << 20
3634

37-
// maxAvatarPixels caps the declared pixel count of an avatar before any raster
38-
// decode is allowed. Without this, a tiny compressed "decompression bomb" image
39-
// declaring e.g. 65535x65535 px would force image.Decode to allocate gigabytes
40-
// of pixel memory and OOM the auth service on a single login attempt. 16 MP
41-
// covers any realistic avatar (~4096x4096) while keeping peak allocation bounded.
35+
// maxAvatarPixels caps cfg.Width*cfg.Height (from image.DecodeConfig) before any
36+
// full image.Decode runs. Defends against decompression-bomb inputs.
4237
const maxAvatarPixels = 16 * 1024 * 1024
4338

4439
// Proxy provides http handler for avatars from avatar.Store
@@ -51,7 +46,11 @@ type Proxy struct {
5146
ResizeLimit int
5247
}
5348

54-
// Put stores retrieved avatar to avatar.Store. Gets image from user info. Returns proxied url
49+
// Put fetches u.Picture, validates and optionally resizes the body, stores it via
50+
// Store, and returns the proxied avatar URL. If u.Picture is empty, the fetch fails,
51+
// or the upstream bytes are not a recognized image format within the configured
52+
// dimension and size limits, Put silently falls back to a generated identicon and
53+
// returns its proxied URL — the caller is not told upstream was rejected.
5554
func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err error) {
5655

5756
genIdenticon := func(userID string) (avatarURL string, err error) {
@@ -168,7 +167,9 @@ func (p *Proxy) load(url string, client *http.Client) ([]byte, error) {
168167
return body, nil
169168
}
170169

171-
// Handler returns token routes for given provider
170+
// Handler serves stored avatar content by avatar id. GET only; rejects invalid ids
171+
// (403) and stored bytes that fail the safeImgContentType sniff (415). Layered
172+
// defense headers are set on every response via setAvatarDefenseHeaders.
172173
func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
173174
setAvatarDefenseHeaders(w)
174175

@@ -195,11 +196,9 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
195196
}
196197
}()
197198

198-
// io.ReadFull keeps reading until the buffer is full or EOF, so a Store
199-
// implementation that returns a buffered reader with a small first-Read size
200-
// won't cause DetectContentType to misclassify a real image. Short bodies
201-
// (avatars under sniffLen) return ErrUnexpectedEOF — that's expected, we sniff
202-
// what we got.
199+
// ReadFull (not Read) so a Store backend that hands back a short first Read
200+
// doesn't truncate the sniff window. Short bodies are signaled by
201+
// ErrUnexpectedEOF and treated like EOF.
203202
buf := make([]byte, sniffLen)
204203
n, err := io.ReadFull(avReader, buf)
205204
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
@@ -208,11 +207,9 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
208207
return
209208
}
210209

211-
// validate the bytes really are an image before declaring a content type. Even
212-
// though Put() now refuses to store non-image content, this catches stores poisoned
213-
// before the fix and any future regression — never trust the bytes at the store
214-
// boundary alone, validate again at serve time. An empty body (e.g. NoOp store)
215-
// is treated as a benign no-content case: nothing to render, no XSS surface.
210+
// sniff buf[:n] against the safeImgContentType allowlist. This is not a full
211+
// image decode; it catches stores poisoned before Put validated. An empty body
212+
// (e.g. NoOp store) is treated as benign — nothing to render.
216213
var contentType string
217214
if n > 0 {
218215
var ctErr error
@@ -248,20 +245,12 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
248245
}
249246
}
250247

251-
// resize validates that the input is a real image and, if needed, re-encodes it to
252-
// fit within "limit" px on the larger side preserving aspect ratio. Returns nil for
253-
// non-image content or for declared dimensions exceeding maxAvatarPixels so attacker
254-
// payloads (HTML/SVG/decompression bombs) never reach the store. With limit <= 0 or
255-
// when the image already fits, the original bytes are returned verbatim so animated
256-
// GIFs and other multi-frame formats round-trip without being flattened to one frame.
257-
//
258-
// Validation uses image.DecodeConfig (cheap — declares dimensions, allocates nothing)
259-
// before any full image.Decode, so a 100 KB compressed image declaring 65535x65535 px
260-
// is rejected without ever materializing the raster.
261-
//
262-
// Callers (load, PutContent, identicon generation) must ensure body is bounded by
263-
// maxAvatarFetchSize before calling; resize trusts the size invariant rather than
264-
// re-buffering. An empty body or a body over the cap is refused defensively.
248+
// resize checks body via image.DecodeConfig (format + dimensions only, no raster
249+
// allocation), then either returns the original bytes verbatim (limit <= 0, or
250+
// dimensions already within limit) or fully decodes and re-encodes to PNG fitting
251+
// within limit px on the larger side. Returns nil for non-image input or for
252+
// dimensions exceeding maxAvatarPixels. Caller must ensure body is within
253+
// maxAvatarFetchSize; a body over the cap is also refused defensively.
265254
func (p *Proxy) resize(body []byte, limit int) io.Reader {
266255
if len(body) == 0 || int64(len(body)) > maxAvatarFetchSize {
267256
p.Logf("[WARN] avatar resize(): refusing body of size %d (cap %d)", len(body), maxAvatarFetchSize)
@@ -315,13 +304,9 @@ func (p *Proxy) resize(body []byte, limit int) io.Reader {
315304
return &out
316305
}
317306

318-
// safeImgContentType returns the sniffed content type if the bytes look like a safe
319-
// raster image format. The set is an explicit allowlist (PNG, JPEG, GIF, WebP, BMP,
320-
// ICO) — no HasPrefix("image/") catch-all — so future scriptable image/* MIME types
321-
// added by http.DetectContentType cannot silently pass. Returns an error otherwise.
322-
// image/svg+xml is excluded because SVG can execute scripts when navigated to top
323-
// level; image/* coverage of icon files uses both spellings http.DetectContentType
324-
// is known to return.
307+
// safeImgContentType returns the sniffed Content-Type for img if it is one of the
308+
// allow-listed raster image formats (PNG, JPEG, GIF, WebP, BMP, ICO under both
309+
// MIME spellings http.DetectContentType uses), else an error. SVG is excluded.
325310
func safeImgContentType(img []byte) (string, error) {
326311
ct := http.DetectContentType(img)
327312
base := ct

0 commit comments

Comments
 (0)