Skip to content

Commit 415d7ca

Browse files
committed
docs: package-wide comment sweep
Audit prompted by review feedback on PR #290's docstrings — the security PR landed several long, rationale-heavy doc comments and a couple of them turned out factually wrong. A combined Codex + Explore-agent sweep over the rest of the v1 package surfaced ~20 more drift items in pre-existing comments. This commit fixes both classes in one pass and trims the rationale prose from the security-fix docs down to API-contract sized statements. Net 28 files changed across v1 and v2; the line count drops by ~30 because most of the change is shortening, not adding. Build green, lint clean, go fix clean on both modules. Drift fixes (pre-existing wrong/misleading comments): * avatar/gridfs.go — ID doc claimed MD5 sourced from gridfs; in reality metadata.hash is the sha1 written at Put time. * avatar/bolt.go, avatar/gridfs.go — Put docs and BoltDB type doc claimed these layers "resize" the image; they only copy bytes verbatim, resize happens in Proxy.resize upstream. * avatar/store.go — Migrate doc didn't mention that per-avatar Get/Put/Close errors are logged and skipped, and that the returned count is "ids attempted", not "ids stored". * provider/oauth1.go — initOauth1Handler doc and DEBUG log both said "oauth2"; the function and the protocol are OAuth 1. * provider/service.go — Handler doc said it "returns auth routes"; it dispatches login/callback/logout. * provider/telegram.go — processUpdates claimed to return an offset (no return value); checkToken doc described an "address or empty string" return shape but the signature is (*token.User, error). * provider/verify.go — Sender interface doc locked the contract to "send emails", contradicting the broader "email, IM, or anything else" promise on VerifyHandler at line 23. AuthHandler comment was copy-pasted from the direct provider. * provider/dev_provider.go — NewDev doc said "for admin user"; it makes the dev oauth2 provider; admin role is separate. * middleware/user_updater.go — UserUpdFunc adapter doc said the result "is a Handler"; it implements UserUpdater. * logger/interface.go — Func adapter doc said "Logf calls f(id)"; calls f(format, args...). * token/jwt.go — SendJWTHeader option said "instead of cookie"; Set sends header and cookie. Set doc referenced a "permanent flag" that doesn't exist in the signature (controlled by claims.SessionOnly/Handshake). Get doc oversimplified the XSRF gate (skipped when DisableXSRF, method in XSRFIgnoreMethods, or claims have no user). Update/Validate adapter docs said "calls f(id)"; actually f(claims) and f(token, claims). * token/user.go — HashID inline "or empty" was wrong; an empty val never matches reValidSha. * auth.go — BasicAuthChecker doc grammar was broken and understated behavior; AdminPasswd is bypassed entirely when a checker is set, not "ignored". "peak dev provider" typo for "peek". AvatarProxy doc was a sentence fragment. Trims (rationale prose I wrote in #290; reduced to API contract): * avatar/avatar.go — maxAvatarFetchSize, maxAvatarPixels, Proxy.Put, Proxy.Handler godoc, Handler inline sniff comment, Proxy.resize, safeImgContentType — each cut to what callers/maintainers need to know about behavior, with rationale moved to the commit history. * auth.go — withSecurityHeaders dropped its bullet-list explanation of standard HTTP headers; the CONSUMER NOTE is preserved (real footgun) but tightened. All changes mirrored across v1 and v2.
1 parent b19c8d7 commit 415d7ca

28 files changed

Lines changed: 156 additions & 186 deletions

auth.go

Lines changed: 13 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ type Opts struct {
8181
UseGravatar bool // for email based auth (verified provider) use gravatar service
8282

8383
AdminPasswd string // if presented, allows basic auth with user admin and given password
84-
BasicAuthChecker middleware.BasicAuthFunc // user custom checker for basic auth, if one defined then "AdminPasswd" will ignored
84+
BasicAuthChecker middleware.BasicAuthFunc // custom checker for basic auth; when set, AdminPasswd is bypassed entirely
8585
AudienceReader token.Audience // list of allowed aud values, default (empty) allows any
8686
AudSecrets bool // allow multiple secrets (secret per aud)
8787
Logger logger.L // logger interface, default is no logging at all
@@ -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'")
@@ -516,7 +503,7 @@ func (s *Service) AddCustomHandler(p provider.Provider) {
516503

517504
// DevAuth makes dev oauth2 server, for testing and development only!
518505
func (s *Service) DevAuth() (*provider.DevAuthServer, error) {
519-
p, err := s.Provider("dev") // peak dev provider
506+
p, err := s.Provider("dev") // peek dev provider
520507
if err != nil {
521508
return nil, fmt.Errorf("dev provider not registered: %w", err)
522509
}
@@ -544,7 +531,7 @@ func (s *Service) TokenService() *token.Service {
544531
return s.jwtService
545532
}
546533

547-
// AvatarProxy returns stored in service
534+
// AvatarProxy returns the avatar.Proxy configured on the service, or nil if no AvatarStore was set.
548535
func (s *Service) AvatarProxy() *avatar.Proxy {
549536
return s.avatarProxy
550537
}

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 it via resize, stores it via Store, and returns
50+
// the proxied avatar URL. If u.Picture is empty, the fetch fails, or the body is
51+
// not a recognized image within the size and dimension limits, Put silently stores
52+
// a generated identicon and returns its URL — the caller is not told upstream was
53+
// 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

avatar/bolt.go

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111

1212
// BoltDB implements avatar store with bolt
1313
// using separate db (file) with "avatars" bucket to keep image bin and "metas" bucket
14-
// to keep sha1 of picture. avatarID (base file name) used as a key for both.
14+
// to keep a sha1 fingerprint of the stored bytes. avatarID (base file name) used as a key for both.
1515
type BoltDB struct {
1616
fileName string // full path to boltdb
1717
db *bolt.DB
@@ -41,7 +41,9 @@ func NewBoltDB(fileName string, options bolt.Options) (*BoltDB, error) {
4141
return &BoltDB{db: db, fileName: fileName}, nil
4242
}
4343

44-
// Put avatar to bolt, key by avatarID. Trying to resize image and lso calculates sha1 of the file for ID func
44+
// Put stores avatar bytes read from reader in the "avatars" bucket and a sha1 of the
45+
// same bytes in the "metas" bucket, both keyed by the encoded userID with .image suffix.
46+
// Resizing happens upstream in Proxy.resize; this layer only writes what it is given.
4547
func (b *BoltDB) Put(userID string, reader io.Reader) (avatar string, err error) {
4648
id := encodeID(userID)
4749

avatar/gridfs.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ type GridFS struct {
2727
timeout time.Duration
2828
}
2929

30-
// Put avatar to gridfs object, try to resize
30+
// Put stores avatar bytes read from reader in GridFS, keyed by the encoded userID
31+
// with .image suffix, and records the sha1 of those bytes in the file's metadata.
32+
// Resizing happens upstream in Proxy.resize; this layer only writes what it is given.
3133
func (gf *GridFS) Put(userID string, reader io.Reader) (avatar string, err error) {
3234
id := encodeID(userID)
3335
bucket, err := gridfs.NewBucket(gf.db, &options.BucketOptions{Name: &gf.bucketName})
@@ -59,7 +61,8 @@ func (gf *GridFS) Get(avatar string) (reader io.ReadCloser, size int, err error)
5961
return io.NopCloser(buf), int(sz), nil
6062
}
6163

62-
// ID returns a fingerprint of the avatar content. Uses MD5 because gridfs provides it directly
64+
// ID returns the sha1 fingerprint of the avatar content stored in the GridFS file's
65+
// metadata at Put time, or an encoded fallback id when the file or hash is missing.
6366
func (gf *GridFS) ID(avatar string) (id string) {
6467

6568
finfo := struct {

avatar/store.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ func NewStore(uri string) (Store, error) {
7070
return nil, fmt.Errorf("can't parse store url %s", uri)
7171
}
7272

73-
// Migrate avatars between stores
73+
// Migrate copies avatars from src to dst, returning the number of ids enumerated
74+
// from src and the first List error if any. Per-avatar Get/Put/Close failures are
75+
// logged with [WARN] and skipped — they do not abort the migration or surface in
76+
// the returned error, so the returned count is "ids attempted", not "ids stored".
7477
func Migrate(dst, src Store) (int, error) {
7578
ids, err := src.List()
7679
if err != nil {

logger/interface.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type L interface {
1212
// Func type is an adapter to allow the use of ordinary functions as Logger.
1313
type Func func(format string, args ...any)
1414

15-
// Logf calls f(id)
15+
// Logf calls f(format, args...).
1616
func (f Func) Logf(format string, args ...any) { f(format, args...) }
1717

1818
// NoOp logger

middleware/user_updater.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ type UserUpdater interface {
1212
}
1313

1414
// UserUpdFunc type is an adapter to allow the use of ordinary functions as UserUpdater. If f is a function
15-
// with the appropriate signature, UserUpdFunc(f) is a Handler that calls f.
15+
// with the appropriate signature, UserUpdFunc(f) is a UserUpdater that calls f.
1616
type UserUpdFunc func(user token.User) token.User
1717

1818
// Update calls f(user)

provider/dev_provider.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ func (d *DevAuthServer) Shutdown() {
167167
d.lock.Unlock()
168168
}
169169

170-
// NewDev makes dev oauth2 provider for admin user
170+
// NewDev makes a dev oauth2 provider intended for local development only.
171171
func NewDev(p Params) Oauth2Handler {
172172
if p.Port == 0 {
173173
p.Port = defDevAuthPort

provider/oauth1.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ func (h Oauth1Handler) makeRedirURL(path string) string {
190190
return strings.TrimSuffix(h.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix
191191
}
192192

193-
// initOauth2Handler makes oauth1 handler for given provider
193+
// initOauth1Handler makes oauth1 handler for given provider
194194
func initOauth1Handler(p Params, service Oauth1Handler) Oauth1Handler {
195195
if p.L == nil {
196196
p.L = logger.NoOp
@@ -200,7 +200,7 @@ func initOauth1Handler(p Params, service Oauth1Handler) Oauth1Handler {
200200
service.conf.ConsumerKey = p.Cid
201201
service.conf.ConsumerSecret = p.Csecret
202202

203-
p.Logf("[DEBUG] created %s oauth2, id=%s, redir=%s, endpoint=%s",
203+
p.Logf("[DEBUG] created %s oauth1, id=%s, redir=%s, endpoint=%s",
204204
service.name, service.Cid, service.makeRedirURL("/{route}/"+service.name+"/"), service.conf.Endpoint)
205205
return service
206206
}

provider/service.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ type Provider interface {
6363
LogoutHandler(w http.ResponseWriter, r *http.Request)
6464
}
6565

66-
// Handler returns auth routes for given provider
66+
// Handler dispatches login, callback, and logout requests to the underlying provider.
6767
func (p Service) Handler(w http.ResponseWriter, r *http.Request) {
6868

6969
if r.Method != http.MethodGet && r.Method != http.MethodPost {

0 commit comments

Comments
 (0)