chore(deps): bump go modules in backend and example
Backend (backend/go.mod): - github.com/go-pkgz/auth/v2 v2.1.2 → v2.1.4 - github.com/klauspost/compress v1.18.5 → v1.18.6 - github.com/redis/go-redis/v9 v9.18.0 → v9.19.0 - github.com/slack-go/slack v0.21.1 → v0.23.1 - golang.org/x/crypto v0.50.0 → v0.51.0 - golang.org/x/image v0.39.0 → v0.40.0 - golang.org/x/net v0.53.0 → v0.54.0 - golang.org/x/sys v0.43.0 → v0.44.0 - golang.org/x/text v0.36.0 → v0.37.0 Example (backend/_example/memory_store/go.mod): - golang.org/x/crypto v0.50.0 → v0.51.0 - golang.org/x/image v0.39.0 → v0.40.0 - golang.org/x/net v0.53.0 → v0.54.0 - golang.org/x/sys v0.43.0 → v0.44.0 Transitive cleanup: github.com/dgryski/go-rendezvous is no longer required after redis/go-redis bump and gets pruned by `go mod tidy`. `go mod tidy` + `go mod vendor` run on both modules. Both build with -race and full test suites pass.
This commit is contained in:
committed by
Umputun
parent
f3a7dea1f1
commit
45c17a913f
+69
-9
@@ -7,6 +7,7 @@ import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/rest"
|
||||
@@ -26,14 +27,16 @@ type Client struct {
|
||||
|
||||
// Service provides higher level wrapper allowing to construct everything and get back token middleware
|
||||
type Service struct {
|
||||
logger logger.L
|
||||
opts Opts
|
||||
jwtService *token.Service
|
||||
providers []provider.Service
|
||||
authMiddleware middleware.Authenticator
|
||||
avatarProxy *avatar.Proxy
|
||||
issuer string
|
||||
useGravatar bool
|
||||
logger logger.L
|
||||
opts Opts
|
||||
jwtService *token.Service
|
||||
providers []provider.Service
|
||||
authMiddleware middleware.Authenticator
|
||||
avatarProxy *avatar.Proxy
|
||||
issuer string
|
||||
useGravatar bool
|
||||
verifConfirmStore provider.VerifConfirmationStore
|
||||
verifConfirmStoreOnce sync.Once
|
||||
}
|
||||
|
||||
// Opts is a full set of all parameters to initialize Service
|
||||
@@ -84,6 +87,15 @@ type Opts struct {
|
||||
Logger logger.L // logger interface, default is no logging at all
|
||||
RefreshCache middleware.RefreshCache // optional cache to keep refreshed tokens
|
||||
ErrorHandler middleware.ErrorHandlerFunc // custom error handler for auth failures
|
||||
|
||||
// VerifConfirmationStore enforces one-shot consumption of email
|
||||
// confirmation tokens issued by the verify provider. The default
|
||||
// (nil) installs an in-memory store on first use of AddVerifProvider —
|
||||
// fine for single-instance deployments. Multi-instance deployments
|
||||
// MUST supply a shared backend (e.g. Redis) implementing
|
||||
// provider.VerifConfirmationStore, otherwise replay rejection works
|
||||
// only on the instance that consumed the token.
|
||||
VerifConfirmationStore provider.VerifConfirmationStore
|
||||
}
|
||||
|
||||
// NewService initializes everything
|
||||
@@ -225,7 +237,39 @@ func (s *Service) Handlers() (authHandler, avatarHandler http.Handler) {
|
||||
p.Handler(w, r)
|
||||
}
|
||||
|
||||
return http.HandlerFunc(ah), http.HandlerFunc(s.avatarProxy.Handler)
|
||||
return withSecurityHeaders(http.HandlerFunc(ah)), withSecurityHeaders(http.HandlerFunc(s.avatarProxy.Handler))
|
||||
}
|
||||
|
||||
// withSecurityHeaders wraps an auth response handler to apply strict CSP and nosniff
|
||||
// on every response. The go-pkgz/auth package's own response surface is JSON-only
|
||||
// for auth routes and images for the avatar route — no built-in HTML rendering
|
||||
// anywhere — so this CSP is unconditionally safe and gives the auth origin
|
||||
// defense-in-depth against any future trust-boundary regression that might emit a
|
||||
// renderable body.
|
||||
//
|
||||
// - Content-Security-Policy: default-src 'none'; sandbox — blocks inline scripts
|
||||
// and event handlers even if a body is ever served as HTML by mistake; the
|
||||
// sandbox directive additionally isolates any rendered document from this origin.
|
||||
// - X-Content-Type-Options: nosniff — prevents browsers from MIME-overriding the
|
||||
// declared Content-Type to a more dangerous one.
|
||||
//
|
||||
// The avatar Handler additionally sets Content-Disposition: inline; filename="avatar"
|
||||
// inside itself, so direct callers (tests, custom mounts) still get the full header
|
||||
// set without going through this wrapper.
|
||||
//
|
||||
// CONSUMER NOTE: custom providers added via Service.AddCustomHandler / AddProvider
|
||||
// are also wrapped. If a custom provider renders HTML (login forms, JS-based flows,
|
||||
// the dev_provider's login page, etc.), the strict CSP will block inline scripts and
|
||||
// event handlers on those pages. Such providers should either (a) override the CSP
|
||||
// for their own response by calling w.Header().Set("Content-Security-Policy", ...)
|
||||
// before writing — Set replaces the wrapper's value — or (b) move any required
|
||||
// scripts/styles to external files served from 'self'.
|
||||
func withSecurityHeaders(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Middleware returns auth middleware
|
||||
@@ -435,6 +479,21 @@ func (s *Service) AddDirectProviderWithUserIDFunc(name string, credChecker provi
|
||||
|
||||
// AddVerifProvider adds provider user's verification sent by sender
|
||||
func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender) {
|
||||
s.verifConfirmStoreOnce.Do(func() {
|
||||
store := s.opts.VerifConfirmationStore
|
||||
// guard against a typed-nil VerifConfirmationStoreFunc: a non-nil
|
||||
// interface wrapping a nil func would survive the != nil check below
|
||||
// and silently disable replay protection (the handler-level guard
|
||||
// at LoginHandler then normalizes it to nil).
|
||||
if fn, ok := store.(provider.VerifConfirmationStoreFunc); ok && fn == nil {
|
||||
store = nil
|
||||
}
|
||||
if store != nil {
|
||||
s.verifConfirmStore = store
|
||||
return
|
||||
}
|
||||
s.verifConfirmStore = provider.NewInMemoryVerifStore()
|
||||
})
|
||||
dh := provider.VerifyHandler{
|
||||
L: s.logger,
|
||||
ProviderName: name,
|
||||
@@ -446,6 +505,7 @@ func (s *Service) AddVerifProvider(name, msgTmpl string, sender provider.Sender)
|
||||
UseGravatar: s.useGravatar,
|
||||
URL: s.opts.URL,
|
||||
AllowedRedirectHosts: s.opts.AllowedRedirectHosts,
|
||||
ConfirmationStore: s.verifConfirmStore,
|
||||
}
|
||||
s.addProvider(dh)
|
||||
}
|
||||
|
||||
+217
-54
@@ -11,6 +11,7 @@ import (
|
||||
"image/png"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -18,6 +19,7 @@ import (
|
||||
"github.com/go-pkgz/rest"
|
||||
"github.com/rrivera/identicon"
|
||||
"golang.org/x/image/draw"
|
||||
_ "golang.org/x/image/webp" // register WebP decoder so Discord-style .webp avatars validate
|
||||
|
||||
"github.com/go-pkgz/auth/v2/logger"
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
@@ -26,6 +28,19 @@ import (
|
||||
// http.sniffLen is 512 bytes which is how much we need to read to detect content type
|
||||
const sniffLen = 512
|
||||
|
||||
// maxAvatarFetchSize bounds the bytes read from a remote avatar URL. 10 MiB is
|
||||
// generous for any reasonable avatar (Telegram caps photo at 5 MiB; Gravatar is
|
||||
// much smaller); the cap protects Proxy.Put against an upstream sending an
|
||||
// unbounded body that would exhaust process memory inside resize.
|
||||
const maxAvatarFetchSize = 10 << 20
|
||||
|
||||
// maxAvatarPixels caps the declared pixel count of an avatar before any raster
|
||||
// decode is allowed. Without this, a tiny compressed "decompression bomb" image
|
||||
// declaring e.g. 65535x65535 px would force image.Decode to allocate gigabytes
|
||||
// of pixel memory and OOM the auth service on a single login attempt. 16 MP
|
||||
// covers any realistic avatar (~4096x4096) while keeping peak allocation bounded.
|
||||
const maxAvatarPixels = 16 * 1024 * 1024
|
||||
|
||||
// Proxy provides http handler for avatars from avatar.Store
|
||||
// On user login token will call Put and it will retrieve and save picture locally.
|
||||
type Proxy struct {
|
||||
@@ -45,7 +60,7 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er
|
||||
return "", fmt.Errorf("no picture for %s: %w", userID, e)
|
||||
}
|
||||
// put returns avatar base name, like 123456.image
|
||||
avatarID, e := p.Store.Put(userID, p.resize(bytes.NewBuffer(b), p.ResizeLimit))
|
||||
avatarID, e := p.Store.Put(userID, p.resize(b, p.ResizeLimit))
|
||||
if e != nil {
|
||||
return "", e
|
||||
}
|
||||
@@ -61,30 +76,73 @@ func (p *Proxy) Put(u token.User, client *http.Client) (avatarURL string, err er
|
||||
|
||||
body, err := p.load(u.Picture, client)
|
||||
if err != nil {
|
||||
p.Logf("[DEBUG] failed to fetch avatar from the orig %s, %v", u.Picture, err)
|
||||
p.Logf("[DEBUG] failed to fetch avatar from the orig %s, %v", redactAvatarURL(u.Picture), err)
|
||||
return genIdenticon(u.ID)
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if e := body.Close(); e != nil {
|
||||
p.Logf("[WARN] can't close response body, %s", e)
|
||||
}
|
||||
}()
|
||||
resized := p.resize(body, p.ResizeLimit)
|
||||
if resized == nil {
|
||||
// non-image upstream — refuse to store attacker-controlled bytes under
|
||||
// the user's avatar id and fall back to a generated identicon instead.
|
||||
p.Logf("[WARN] upstream avatar from %s is not a valid image, using identicon", redactAvatarURL(u.Picture))
|
||||
return genIdenticon(u.ID)
|
||||
}
|
||||
|
||||
avatarID, err := p.Store.Put(u.ID, p.resize(body, p.ResizeLimit)) // put returns avatar base name, like 123456.image
|
||||
avatarID, err := p.Store.Put(u.ID, resized) // put returns avatar base name, like 123456.image
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", u.Picture, avatarID, u.Name)
|
||||
p.Logf("[DEBUG] saved avatar from %s to %s, user %q", redactAvatarURL(u.Picture), avatarID, u.Name)
|
||||
return p.URL + p.RoutePath + "/" + avatarID, nil
|
||||
}
|
||||
|
||||
// load avatar from remote url and return body. Caller has to close the reader
|
||||
func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err error) {
|
||||
// load avatar from remote location
|
||||
// PutContent stores already-fetched avatar bytes via the underlying Store and returns
|
||||
// the proxied URL. It exists so providers that authenticate with credentials embedded
|
||||
// in the upstream URL (e.g. Telegram bot file API: /file/bot{TOKEN}/...) can fetch the
|
||||
// content themselves and avoid exposing the credential to Put's URL-fetching path —
|
||||
// where it would land in u.Picture, debug logs, and the user JSON returned to clients.
|
||||
//
|
||||
// Bytes are read into memory bounded by maxAvatarFetchSize so an unbounded caller
|
||||
// (e.g. a streaming HTTP body) cannot exhaust process memory.
|
||||
func (p *Proxy) PutContent(userID string, content io.Reader) (avatarURL string, err error) {
|
||||
body, err := io.ReadAll(io.LimitReader(content, maxAvatarFetchSize+1))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to read avatar content for %s: %w", userID, err)
|
||||
}
|
||||
if int64(len(body)) > maxAvatarFetchSize {
|
||||
return "", fmt.Errorf("avatar content for %s exceeds %d bytes", userID, maxAvatarFetchSize)
|
||||
}
|
||||
resized := p.resize(body, p.ResizeLimit)
|
||||
if resized == nil {
|
||||
return "", fmt.Errorf("avatar content for %s is not a valid image", userID)
|
||||
}
|
||||
avatarID, err := p.Store.Put(userID, resized)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
p.Logf("[DEBUG] saved avatar bytes to %s, user %q", avatarID, userID)
|
||||
return p.URL + p.RoutePath + "/" + avatarID, nil
|
||||
}
|
||||
|
||||
// redactAvatarURL returns the hostname only, dropping scheme, userinfo, path,
|
||||
// query and fragment. This is enough to keep avatar URLs identifiable in logs
|
||||
// while ensuring credentials carried in any of those parts (e.g. Telegram bot
|
||||
// tokens, time-limited signed-URL tokens, basic-auth in userinfo) don't reach
|
||||
// log destinations. On parse failure a sentinel is returned.
|
||||
func redactAvatarURL(raw string) string {
|
||||
if u, err := url.Parse(raw); err == nil && u.Hostname() != "" {
|
||||
return u.Hostname()
|
||||
}
|
||||
return "<unparseable>"
|
||||
}
|
||||
|
||||
// load fetches an avatar from a remote url and returns the body bytes, capped at
|
||||
// maxAvatarFetchSize. The bytes are passed straight to resize without an
|
||||
// intermediate Reader wrapper so we don't pay for buffering twice.
|
||||
func (p *Proxy) load(url string, client *http.Client) ([]byte, error) {
|
||||
var resp *http.Response
|
||||
err = retry(5, time.Second, func() error {
|
||||
err := retry(5, time.Second, func() error {
|
||||
var e error
|
||||
resp, e = client.Get(url)
|
||||
return e
|
||||
@@ -92,19 +150,31 @@ func (p *Proxy) load(url string, client *http.Client) (rc io.ReadCloser, err err
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to fetch avatar from the orig: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
_ = resp.Body.Close() // caller won't close on error
|
||||
return nil, fmt.Errorf("failed to get avatar from the orig, status %s", resp.Status)
|
||||
}
|
||||
|
||||
return resp.Body, nil
|
||||
// buffer the body up to the cap to fail fast on oversized inputs.
|
||||
// Reading +1 byte beyond the cap distinguishes "exactly cap" from "too big".
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxAvatarFetchSize+1))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read avatar body: %w", err)
|
||||
}
|
||||
if int64(len(body)) > maxAvatarFetchSize {
|
||||
return nil, fmt.Errorf("avatar body exceeds %d bytes", maxAvatarFetchSize)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
// Handler returns token routes for given provider
|
||||
func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
setAvatarDefenseHeaders(w)
|
||||
|
||||
if r.Method != "GET" {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
elems := strings.Split(r.URL.Path, "/")
|
||||
avatarID := elems[len(elems)-1]
|
||||
@@ -113,19 +183,6 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// enforce client-side caching
|
||||
etag := `"` + p.Store.ID(avatarID) + `"`
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
|
||||
if match := r.Header.Get("If-None-Match"); match != "" {
|
||||
etag = strings.TrimPrefix(etag, `"`)
|
||||
etag = strings.TrimSuffix(etag, `"`)
|
||||
if match == etag {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
avReader, size, err := p.Store.Get(avatarID)
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, p.L, http.StatusBadRequest, err, "can't load avatar")
|
||||
@@ -138,18 +195,47 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}()
|
||||
|
||||
// io.ReadFull keeps reading until the buffer is full or EOF, so a Store
|
||||
// implementation that returns a buffered reader with a small first-Read size
|
||||
// won't cause DetectContentType to misclassify a real image. Short bodies
|
||||
// (avatars under sniffLen) return ErrUnexpectedEOF — that's expected, we sniff
|
||||
// what we got.
|
||||
buf := make([]byte, sniffLen)
|
||||
n, err := avReader.Read(buf)
|
||||
if err != nil && err != io.EOF {
|
||||
n, err := io.ReadFull(avReader, buf)
|
||||
if err != nil && err != io.EOF && err != io.ErrUnexpectedEOF {
|
||||
p.Logf("[WARN] can't read from avatar reader for %s, %s", avatarID, err)
|
||||
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "can't read avatar")
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Length", strconv.Itoa(size))
|
||||
contentType := http.DetectContentType(buf)
|
||||
if contentType == "application/octet-stream" {
|
||||
|
||||
// validate the bytes really are an image before declaring a content type. Even
|
||||
// though Put() now refuses to store non-image content, this catches stores poisoned
|
||||
// before the fix and any future regression — never trust the bytes at the store
|
||||
// boundary alone, validate again at serve time. An empty body (e.g. NoOp store)
|
||||
// is treated as a benign no-content case: nothing to render, no XSS surface.
|
||||
var contentType string
|
||||
if n > 0 {
|
||||
var ctErr error
|
||||
contentType, ctErr = safeImgContentType(buf[:n])
|
||||
if ctErr != nil {
|
||||
p.Logf("[WARN] rejecting non-image avatar %s: %v", avatarID, ctErr)
|
||||
rest.SendErrorJSON(w, r, p.L, http.StatusUnsupportedMediaType, ctErr, "invalid avatar content")
|
||||
return
|
||||
}
|
||||
} else {
|
||||
contentType = "image/*"
|
||||
}
|
||||
|
||||
// caching headers only after validation so error responses aren't cached
|
||||
etag := `"` + p.Store.ID(avatarID) + `"`
|
||||
w.Header().Set("Etag", etag)
|
||||
w.Header().Set("Cache-Control", "max-age=604800") // 7 days
|
||||
if match := r.Header.Get("If-None-Match"); match != "" && etagMatches(match, etag) {
|
||||
w.WriteHeader(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Length", strconv.Itoa(size))
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
if _, err = w.Write(buf[:n]); err != nil {
|
||||
@@ -162,33 +248,57 @@ func (p *Proxy) Handler(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
|
||||
// resize an image of supported format (PNG, JPG, GIF) to the size of "limit" px of the biggest side
|
||||
// (width or height) preserving aspect ratio.
|
||||
// Returns original reader if resizing is not needed or failed.
|
||||
func (p *Proxy) resize(reader io.Reader, limit int) io.Reader {
|
||||
if reader == nil {
|
||||
p.Logf("[WARN] avatar resize(): reader is nil")
|
||||
// resize validates that the input is a real image and, if needed, re-encodes it to
|
||||
// fit within "limit" px on the larger side preserving aspect ratio. Returns nil for
|
||||
// non-image content or for declared dimensions exceeding maxAvatarPixels so attacker
|
||||
// payloads (HTML/SVG/decompression bombs) never reach the store. With limit <= 0 or
|
||||
// when the image already fits, the original bytes are returned verbatim so animated
|
||||
// GIFs and other multi-frame formats round-trip without being flattened to one frame.
|
||||
//
|
||||
// Validation uses image.DecodeConfig (cheap — declares dimensions, allocates nothing)
|
||||
// before any full image.Decode, so a 100 KB compressed image declaring 65535x65535 px
|
||||
// is rejected without ever materializing the raster.
|
||||
//
|
||||
// Callers (load, PutContent, identicon generation) must ensure body is bounded by
|
||||
// maxAvatarFetchSize before calling; resize trusts the size invariant rather than
|
||||
// re-buffering. An empty body or a body over the cap is refused defensively.
|
||||
func (p *Proxy) resize(body []byte, limit int) io.Reader {
|
||||
if len(body) == 0 || int64(len(body)) > maxAvatarFetchSize {
|
||||
p.Logf("[WARN] avatar resize(): refusing body of size %d (cap %d)", len(body), maxAvatarFetchSize)
|
||||
return nil
|
||||
}
|
||||
if limit <= 0 {
|
||||
p.Logf("[DEBUG] avatar resize(): limit should be greater than 0")
|
||||
return reader
|
||||
}
|
||||
|
||||
var teeBuf bytes.Buffer
|
||||
tee := io.TeeReader(reader, &teeBuf)
|
||||
src, _, err := image.Decode(tee)
|
||||
// validate format and dimensions without allocating pixel memory.
|
||||
cfg, _, err := image.DecodeConfig(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
// non-image input must never reach the store: refuse and let the caller
|
||||
// fall back to an identicon. Returning the raw bytes here previously let
|
||||
// an attacker who controlled u.Picture poison the store with HTML/SVG that
|
||||
// the Handler would later serve back with text/html content type.
|
||||
p.Logf("[WARN] avatar resize(): can't decode avatar image, %s", err)
|
||||
return &teeBuf
|
||||
return nil
|
||||
}
|
||||
// multiply in int64 — on 32-bit builds (GOARCH=386, 32-bit arm) the int
|
||||
// product of two 16-bit-or-larger dimensions can overflow and wrap below
|
||||
// maxAvatarPixels, bypassing the cap. GIF's 16-bit logical screen and
|
||||
// JPEG's 16-bit SOF dimensions both hit this if multiplied as int32.
|
||||
if cfg.Width <= 0 || cfg.Height <= 0 || int64(cfg.Width)*int64(cfg.Height) > int64(maxAvatarPixels) {
|
||||
p.Logf("[WARN] avatar resize(): declared dimensions %dx%d exceed safe limit", cfg.Width, cfg.Height)
|
||||
return nil
|
||||
}
|
||||
|
||||
bounds := src.Bounds()
|
||||
w, h := bounds.Dx(), bounds.Dy()
|
||||
if w <= limit && h <= limit || w <= 0 || h <= 0 {
|
||||
p.Logf("[DEBUG] resizing image is smaller that the limit or has 0 size")
|
||||
return &teeBuf
|
||||
if limit <= 0 || (cfg.Width <= limit && cfg.Height <= limit) {
|
||||
p.Logf("[DEBUG] avatar resize(): no resize needed (dim %dx%d, limit %d)", cfg.Width, cfg.Height, limit)
|
||||
return bytes.NewReader(body)
|
||||
}
|
||||
|
||||
// dimensions are bounded — full decode is now safe to allocate.
|
||||
src, _, err := image.Decode(bytes.NewReader(body))
|
||||
if err != nil {
|
||||
p.Logf("[WARN] avatar resize(): decode after dim-check failed, %s", err)
|
||||
return nil
|
||||
}
|
||||
w, h := src.Bounds().Dx(), src.Bounds().Dy()
|
||||
newW, newH := w*limit/h, limit
|
||||
if w > h {
|
||||
newW, newH = limit, h*limit/w
|
||||
@@ -200,11 +310,64 @@ func (p *Proxy) resize(reader io.Reader, limit int) io.Reader {
|
||||
var out bytes.Buffer
|
||||
if err = png.Encode(&out, m); err != nil {
|
||||
p.Logf("[WARN] avatar resize(): can't encode resized avatar to PNG, %s", err)
|
||||
return &teeBuf
|
||||
return bytes.NewReader(body) // fall back to the validated original
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// safeImgContentType returns the sniffed content type if the bytes look like a safe
|
||||
// raster image format. The set is an explicit allowlist (PNG, JPEG, GIF, WebP, BMP,
|
||||
// ICO) — no HasPrefix("image/") catch-all — so future scriptable image/* MIME types
|
||||
// added by http.DetectContentType cannot silently pass. Returns an error otherwise.
|
||||
// image/svg+xml is excluded because SVG can execute scripts when navigated to top
|
||||
// level; image/* coverage of icon files uses both spellings http.DetectContentType
|
||||
// is known to return.
|
||||
func safeImgContentType(img []byte) (string, error) {
|
||||
ct := http.DetectContentType(img)
|
||||
base := ct
|
||||
if idx := strings.Index(base, ";"); idx >= 0 {
|
||||
base = strings.TrimSpace(base[:idx])
|
||||
}
|
||||
switch base {
|
||||
case "image/png", "image/jpeg", "image/gif", "image/webp", "image/bmp",
|
||||
"image/x-icon", "image/vnd.microsoft.icon":
|
||||
return base, nil
|
||||
}
|
||||
return "", fmt.Errorf("non-image content type %q", ct)
|
||||
}
|
||||
|
||||
// etagMatches reports whether the If-None-Match header value matches the response
|
||||
// ETag per RFC 7232: the header is a comma-separated list of opaque-tags (each in
|
||||
// double quotes), optionally weak-prefixed with W/. The wildcard "*" matches anything.
|
||||
// We deliberately ignore weak/strong distinction because avatar responses are static
|
||||
// per id — both forms identify the same resource.
|
||||
func etagMatches(header, etag string) bool {
|
||||
header = strings.TrimSpace(header)
|
||||
if header == "*" {
|
||||
return true
|
||||
}
|
||||
for tag := range strings.SplitSeq(header, ",") {
|
||||
tag = strings.TrimSpace(tag)
|
||||
tag = strings.TrimPrefix(tag, "W/")
|
||||
if tag == etag {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// setAvatarDefenseHeaders applies layered defense headers on every avatar response
|
||||
// (success, 304, or error). Each header survives content-type validation regressions,
|
||||
// browser sniffing, and top-level navigation:
|
||||
// - Content-Security-Policy: strict, with sandbox — blocks inline scripts/handlers
|
||||
// - X-Content-Type-Options: nosniff — prevents MIME-overriding the declared type
|
||||
// - Content-Disposition: inline; filename="avatar" — frames the response as a file
|
||||
func setAvatarDefenseHeaders(w http.ResponseWriter) {
|
||||
w.Header().Set("Content-Security-Policy", "default-src 'none'; sandbox; frame-ancestors 'none'")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
w.Header().Set("Content-Disposition", `inline; filename="avatar"`)
|
||||
}
|
||||
|
||||
// GenerateAvatar for give user with identicon
|
||||
func GenerateAvatar(user string) ([]byte, error) {
|
||||
|
||||
@@ -243,7 +406,7 @@ func GetGravatarURL(email string) (res string, err error) {
|
||||
}
|
||||
|
||||
func retry(retries int, delay time.Duration, fn func() error) (err error) {
|
||||
for i := 0; i < retries; i++ {
|
||||
for range retries {
|
||||
if err = fn(); err == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
+5
-5
@@ -6,17 +6,17 @@ import "log"
|
||||
|
||||
// L defined logger interface used everywhere in the package
|
||||
type L interface {
|
||||
Logf(format string, args ...interface{})
|
||||
Logf(format string, args ...any)
|
||||
}
|
||||
|
||||
// Func type is an adapter to allow the use of ordinary functions as Logger.
|
||||
type Func func(format string, args ...interface{})
|
||||
type Func func(format string, args ...any)
|
||||
|
||||
// Logf calls f(id)
|
||||
func (f Func) Logf(format string, args ...interface{}) { f(format, args...) }
|
||||
func (f Func) Logf(format string, args ...any) { f(format, args...) }
|
||||
|
||||
// NoOp logger
|
||||
var NoOp = Func(func(string, ...interface{}) {})
|
||||
var NoOp = Func(func(string, ...any) {})
|
||||
|
||||
// Std logger sends to std default logger directly
|
||||
var Std = Func(func(format string, args ...interface{}) { log.Printf(format, args...) })
|
||||
var Std = Func(func(format string, args ...any) { log.Printf(format, args...) })
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ type ErrorHandlerFunc func(w http.ResponseWriter, r *http.Request, statusCode in
|
||||
var adminUser = token.User{
|
||||
ID: "admin",
|
||||
Name: "admin",
|
||||
Attributes: map[string]interface{}{
|
||||
Attributes: map[string]any{
|
||||
"admin": true,
|
||||
},
|
||||
}
|
||||
@@ -243,7 +243,7 @@ func (a *Authenticator) basicAdminUser(r *http.Request) bool {
|
||||
|
||||
// using ConstantTimeCompare to avoid timing attack
|
||||
if user != "admin" || subtle.ConstantTimeCompare([]byte(passwd), []byte(a.AdminPasswd)) != 1 {
|
||||
a.Logf("[WARN] admin basic auth failed, user/passwd mismatch, %s:%s", user, passwd)
|
||||
a.Logf("[WARN] admin basic auth failed for user %q", user)
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
+41
-5
@@ -13,6 +13,7 @@ import (
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
@@ -76,7 +77,7 @@ type AppleConfig struct {
|
||||
ResponseMode string // changes method of receiving data in callback. Default value "form_post" (https://developer.apple.com/documentation/sign_in_with_apple/request_an_authorization_to_the_sign_in_with_apple_server?changes=_1_2#4066168)
|
||||
|
||||
scopes []string // for this package allow only username scope and UID in token claims. Apple service API provide only "email" and "name" scope values (https://developer.apple.com/documentation/sign_in_with_apple/clientconfigi/3230955-scope)
|
||||
privateKey interface{} // private key from Apple obtained in developer account (the keys section). Required for create the Client Secret (https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
|
||||
privateKey any // private key from Apple obtained in developer account (the keys section). Required for create the Client Secret (https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens#3262048)
|
||||
publicKey crypto.PublicKey // need for validate sign of token
|
||||
clientSecret string // is the JWT client secret will create after first call and then used until expired
|
||||
jwkURL string // URL for fetch JWK Apple keys, need redefine for tests
|
||||
@@ -228,7 +229,7 @@ func (ah *AppleHandler) initPrivateKey() error {
|
||||
}
|
||||
|
||||
// tokenKeyFunc use for verify JWT sign, it receives the parsed token and should return the key for validating.
|
||||
func (ah *AppleHandler) tokenKeyFunc(jwtToken *jwt.Token) (interface{}, error) {
|
||||
func (ah *AppleHandler) tokenKeyFunc(jwtToken *jwt.Token) (any, error) {
|
||||
if jwtToken == nil {
|
||||
return nil, fmt.Errorf("failed to call token keyFunc, because token is nil")
|
||||
}
|
||||
@@ -331,7 +332,7 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, err, "exchange failed")
|
||||
return
|
||||
}
|
||||
ah.Logf("[DEBUG] response data %+v", resp)
|
||||
ah.Logf("[DEBUG] apple exchange response: %s", appleVerificationResponseLogSummary(resp))
|
||||
if resp.Error != "" {
|
||||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("fetch IDtoken response error: %s", resp.Error))
|
||||
return
|
||||
@@ -345,10 +346,22 @@ func (ah AppleHandler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// get token claims for extract uid (and email or name if they exist in scope)
|
||||
// get token claims for extract uid (and email or name if they exist in scope).
|
||||
// jwt v5 parser options enforce iss == https://appleid.apple.com and
|
||||
// aud == ClientID inline so we don't need a separate validate pass.
|
||||
tokenClaims := jwt.MapClaims{}
|
||||
_, err = jwt.ParseWithClaims(resp.IDToken, tokenClaims, keySet.keyFunc)
|
||||
_, err = jwt.ParseWithClaims(resp.IDToken, tokenClaims, keySet.keyFunc,
|
||||
jwt.WithIssuer(appleIDTokenIssuer),
|
||||
jwt.WithAudience(ah.conf.ClientID))
|
||||
if err != nil {
|
||||
// distinguish a confused-deputy reject (iss/aud) from a server-side
|
||||
// parse/sig failure so the handler returns the same 403 + body as
|
||||
// before for the security-relevant case.
|
||||
if errors.Is(err, jwt.ErrTokenInvalidIssuer) || errors.Is(err, jwt.ErrTokenInvalidAudience) {
|
||||
ah.Logf("[WARN] apple id_token rejected: %s", err.Error())
|
||||
rest.SendErrorJSON(w, r, ah.L, http.StatusForbidden, nil, "invalid id_token")
|
||||
return
|
||||
}
|
||||
ah.Logf("[ERROR] failed to get claims: " + err.Error())
|
||||
rest.SendErrorJSON(w, r, ah.L, http.StatusInternalServerError, nil, fmt.Sprintf("failed to token validation, key is invalid: %s", resp.Error))
|
||||
return
|
||||
@@ -549,3 +562,26 @@ func (ah AppleHandler) makeRedirURL(path string) string {
|
||||
|
||||
return strings.TrimRight(ah.URL, "/") + strings.TrimSuffix(newPath, "/") + urlCallbackSuffix
|
||||
}
|
||||
|
||||
// appleVerificationResponseLogSummary formats appleVerificationResponse for safe
|
||||
// logging. The struct's AccessToken, RefreshToken and IDToken fields are
|
||||
// credentials and must never appear verbatim in logs that may be shipped to
|
||||
// centralized logging or third-party observability systems; this helper logs
|
||||
// only their presence (present|missing), the non-secret token type and
|
||||
// expiry, plus any provider-side error string.
|
||||
func appleVerificationResponseLogSummary(r appleVerificationResponse) string {
|
||||
return fmt.Sprintf("type=%s expires_in=%d access_token=%s refresh_token=%s id_token=%s error=%q",
|
||||
r.TokenType, r.ExpiresIn,
|
||||
presence(r.AccessToken), presence(r.RefreshToken), presence(r.IDToken), r.Error)
|
||||
}
|
||||
|
||||
func presence(s string) string {
|
||||
if s == "" {
|
||||
return "missing"
|
||||
}
|
||||
return "present"
|
||||
}
|
||||
|
||||
// appleIDTokenIssuer is the issuer Apple sets on every id_token issued by Sign in with Apple.
|
||||
// see https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user
|
||||
const appleIDTokenIssuer = "https://appleid.apple.com" // #nosec G101 -- public Apple issuer URL, not a credential
|
||||
|
||||
+1
-1
@@ -162,7 +162,7 @@ func (aks *appleKeySet) get(kid string) (keys *applePublicKey, err error) {
|
||||
}
|
||||
|
||||
// keyFunc use for JWT verify with specific public key
|
||||
func (aks *appleKeySet) keyFunc(token *jwt.Token) (interface{}, error) {
|
||||
func (aks *appleKeySet) keyFunc(token *jwt.Token) (any, error) {
|
||||
|
||||
keyID, ok := token.Header["kid"].(string)
|
||||
if !ok {
|
||||
|
||||
+10
-2
@@ -79,17 +79,25 @@ func (c *CustomServer) Run(ctx context.Context) {
|
||||
u, err := url.Parse(c.URL)
|
||||
if err != nil {
|
||||
c.Logf("[ERROR] failed to parse service base URL=%s", c.URL)
|
||||
c.lock.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
_, port, err := net.SplitHostPort(u.Host)
|
||||
host, port, err := net.SplitHostPort(u.Host)
|
||||
if err != nil {
|
||||
c.Logf("[ERROR] failed to get port from URL=%s", c.URL)
|
||||
c.lock.Unlock()
|
||||
return
|
||||
}
|
||||
// hostname from URL is honored; only an explicit non-loopback host
|
||||
// (e.g. "0.0.0.0" baked into c.URL) binds beyond loopback. Empty/
|
||||
// "localhost" falls through to localBindAddr's 127.0.0.1 default.
|
||||
if host == "localhost" {
|
||||
host = ""
|
||||
}
|
||||
|
||||
c.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", port),
|
||||
Addr: localBindAddr(host, port),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
|
||||
+1
-1
@@ -57,7 +57,7 @@ func (d *DevAuthServer) Run(ctx context.Context) { // nolint (gocyclo)
|
||||
}
|
||||
|
||||
d.httpServer = &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", d.Provider.Port),
|
||||
Addr: localBindAddr(d.Provider.Host, fmt.Sprintf("%d", d.Provider.Port)),
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
d.Logf("[DEBUG] dev oauth request %s %s %+v", r.Method, r.URL, r.Header)
|
||||
|
||||
+1
-1
@@ -122,7 +122,7 @@ func (h Oauth1Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
jData := map[string]interface{}{}
|
||||
jData := map[string]any{}
|
||||
if e := json.Unmarshal(data, &jData); e != nil {
|
||||
rest.SendErrorJSON(w, r, h.L, http.StatusInternalServerError, err, "failed to unmarshal user info")
|
||||
return
|
||||
|
||||
+2
-2
@@ -57,7 +57,7 @@ type Params struct {
|
||||
}
|
||||
|
||||
// UserData is type for user information returned from oauth2 providers /info API method
|
||||
type UserData map[string]interface{}
|
||||
type UserData map[string]any
|
||||
|
||||
// Value returns value for key or empty string if not found
|
||||
func (u UserData) Value(key string) string {
|
||||
@@ -197,7 +197,7 @@ func (p Oauth2Handler) AuthHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
jData := map[string]interface{}{}
|
||||
jData := map[string]any{}
|
||||
if e := json.Unmarshal(data, &jData); e != nil {
|
||||
rest.SendErrorJSON(w, r, p.L, http.StatusInternalServerError, err, "failed to unmarshal user info")
|
||||
return
|
||||
|
||||
+6
-2
@@ -79,9 +79,13 @@ func NewEmailClient(emailParams EmailParams, l logger.L) *Email {
|
||||
return &Email{EmailParams: emailParams, L: l, sender: sender}
|
||||
}
|
||||
|
||||
// Send email with given text
|
||||
// Send email with given text. The body is not logged: confirmation emails
|
||||
// sent by the verify provider contain a one-shot magic-link token, and any
|
||||
// party with log access could redeem it before the user does. Logging only
|
||||
// the recipient and body length keeps the line useful for operators
|
||||
// without leaking the credential.
|
||||
func (e *Email) Send(to, text string) error {
|
||||
e.Logf("[DEBUG] send %q to %s", text, to)
|
||||
e.Logf("[DEBUG] send %d-byte message to %s", len(text), to)
|
||||
return e.sender.Send(text, email.Params{
|
||||
From: e.From,
|
||||
To: []string{to},
|
||||
|
||||
+15
@@ -4,6 +4,7 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/sha1"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
@@ -11,6 +12,20 @@ import (
|
||||
"github.com/go-pkgz/auth/v2/token"
|
||||
)
|
||||
|
||||
// localBindAddr returns the listen address for the dev oauth and custom-server
|
||||
// helpers. Both servers are intended for local development and embedded
|
||||
// flows; the historical default ":port" listened on every interface, which
|
||||
// silently exposed the dev OAuth UI to anyone on the LAN. Default the bind
|
||||
// to 127.0.0.1; callers that explicitly want a non-loopback bind can pass
|
||||
// a hostname (e.g. "0.0.0.0" or a specific IP) via Provider.Host (dev) or
|
||||
// the URL host (custom-server).
|
||||
func localBindAddr(host, port string) string {
|
||||
if host == "" {
|
||||
host = "127.0.0.1"
|
||||
}
|
||||
return net.JoinHostPort(host, port)
|
||||
}
|
||||
|
||||
const (
|
||||
urlLoginSuffix = "/login"
|
||||
urlCallbackSuffix = "/callback"
|
||||
|
||||
+126
-8
@@ -3,13 +3,16 @@ package provider
|
||||
//go:generate moq --out telegram_moq_test.go . TelegramAPI
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/sha1"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
neturl "net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
@@ -19,6 +22,7 @@ import (
|
||||
"github.com/go-pkgz/rest"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
|
||||
"github.com/go-pkgz/auth/v2/avatar"
|
||||
"github.com/go-pkgz/auth/v2/logger"
|
||||
authtoken "github.com/go-pkgz/auth/v2/token"
|
||||
)
|
||||
@@ -186,11 +190,18 @@ func (th *TelegramHandler) processUpdates(ctx context.Context, updates *telegram
|
||||
|
||||
id := th.ProviderName + "_" + authtoken.HashID(sha1.New(), fmt.Sprint(update.Message.Chat.ID))
|
||||
|
||||
// avatarURL embeds the bot token in its path
|
||||
// (https://api.telegram.org/file/bot{TOKEN}/...). Never store it in
|
||||
// User.Picture: it would leak through avatar.Proxy.Put logs and, when
|
||||
// no avatar saver is configured, into the JWT and on to the client.
|
||||
// Fetch the bytes here and hand them to the avatar store directly.
|
||||
picture := th.saveTelegramAvatar(ctx, id, avatarURL)
|
||||
|
||||
authRequest.confirmed = true
|
||||
authRequest.user = &authtoken.User{
|
||||
ID: id,
|
||||
Name: update.Message.Chat.Name,
|
||||
Picture: avatarURL,
|
||||
Picture: picture,
|
||||
}
|
||||
|
||||
th.requests.Lock()
|
||||
@@ -294,10 +305,19 @@ func (th *TelegramHandler) LoginHandler(w http.ResponseWriter, r *http.Request)
|
||||
return
|
||||
}
|
||||
|
||||
u, err := setAvatar(th.AvatarSaver, *authUser, &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
|
||||
return
|
||||
// when saveTelegramAvatar already populated Picture with a local proxy
|
||||
// URL, skip the URL-fetching avatar pipeline. Letting setAvatar run
|
||||
// here would have it call Proxy.Put which re-fetches Picture; in
|
||||
// split-DNS / unreachable-internal-Opts.URL deployments that fetch
|
||||
// fails and the identicon fallback would silently overwrite the
|
||||
// stored Telegram bytes with an identicon at the same store path.
|
||||
u := *authUser
|
||||
if u.Picture == "" {
|
||||
u, err = setAvatar(th.AvatarSaver, *authUser, &http.Client{Timeout: 5 * time.Second})
|
||||
if err != nil {
|
||||
rest.SendErrorJSON(w, r, th.L, http.StatusInternalServerError, err, "failed to save avatar to proxy")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
claims := authtoken.Claims{
|
||||
@@ -449,18 +469,18 @@ func (tg *tgAPI) BotInfo(ctx context.Context) (*botInfo, error) {
|
||||
return resp.Result, nil
|
||||
}
|
||||
|
||||
func (tg *tgAPI) request(ctx context.Context, method string, data interface{}) error {
|
||||
func (tg *tgAPI) request(ctx context.Context, method string, data any) error {
|
||||
return repeater.NewFixed(3, time.Millisecond*50).Do(ctx, func() error {
|
||||
url := fmt.Sprintf("https://api.telegram.org/bot%s/%s", tg.token, method)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, http.NoBody)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create request: %w", err)
|
||||
return fmt.Errorf("failed to create request: %w", redactBotURLInErr(err))
|
||||
}
|
||||
|
||||
resp, err := tg.client.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to send request: %w", err)
|
||||
return fmt.Errorf("failed to send request: %w", redactBotURLInErr(err))
|
||||
}
|
||||
defer resp.Body.Close() //nolint gosec // we don't care about response body
|
||||
|
||||
@@ -485,3 +505,101 @@ func (tg *tgAPI) parseError(r io.Reader, statusCode int) error {
|
||||
}
|
||||
return fmt.Errorf("unexpected telegram API status code %d, error: %q", statusCode, tgErr.Description)
|
||||
}
|
||||
|
||||
// avatarContentSaver matches the optional method on AvatarSaver implementations
|
||||
// that can store already-fetched bytes (avatar.Proxy provides one). Used by the
|
||||
// Telegram provider to avoid passing a bot-token-bearing URL through the
|
||||
// URL-fetching avatar pipeline.
|
||||
type avatarContentSaver interface {
|
||||
PutContent(userID string, content io.Reader) (string, error)
|
||||
}
|
||||
|
||||
// saveTelegramAvatar fetches the avatar bytes from a bot-token-bearing Telegram
|
||||
// URL and stores them via th.AvatarSaver, returning a clean local proxy URL.
|
||||
// The bot URL is consumed entirely inside this function so it never reaches
|
||||
// User.Picture, JWT claims, or any debug log of the user object. Returns ""
|
||||
// when the avatar cannot be saved (no URL, no compatible saver, or fetch
|
||||
// failure) — the caller treats that as "no picture" and the avatar pipeline
|
||||
// falls back to identicon as usual.
|
||||
func (th *TelegramHandler) saveTelegramAvatar(ctx context.Context, userID, avatarURL string) string {
|
||||
if avatarURL == "" {
|
||||
return ""
|
||||
}
|
||||
// guard against typed-nil *avatar.Proxy. auth.go skips initializing
|
||||
// res.avatarProxy when Opts.AvatarStore is unset, so AvatarSaver can be
|
||||
// a non-nil interface wrapping a nil *avatar.Proxy. The type assertion
|
||||
// below would still succeed (interface satisfaction is structural), but
|
||||
// PutContent on a nil receiver panics on the first p.Store deref.
|
||||
if th.AvatarSaver == nil || th.AvatarSaver == (*avatar.Proxy)(nil) {
|
||||
th.Logf("[WARN] telegram avatar dropped: AvatarSaver is not configured")
|
||||
return ""
|
||||
}
|
||||
saver, ok := th.AvatarSaver.(avatarContentSaver)
|
||||
if !ok {
|
||||
// fallback intentionally drops the picture rather than expose the bot
|
||||
// token; warn so operators can wire a content-aware saver if they want
|
||||
// telegram avatars saved
|
||||
th.Logf("[WARN] telegram avatar dropped: configured AvatarSaver does not support direct content save")
|
||||
return ""
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, avatarURL, http.NoBody)
|
||||
if err != nil {
|
||||
th.Logf("[WARN] telegram avatar fetch request build failed: %v", redactBotURLInErr(err))
|
||||
return ""
|
||||
}
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
th.Logf("[WARN] telegram avatar fetch failed: %v", redactBotURLInErr(err))
|
||||
return ""
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
th.Logf("[WARN] telegram avatar fetch returned status %d", resp.StatusCode)
|
||||
return ""
|
||||
}
|
||||
// cap body size to protect PutContent from an unbounded upstream response.
|
||||
// Telegram caps photos at 5 MiB; 10 MiB is generous headroom while still
|
||||
// bounding worst-case memory.
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxTelegramAvatarSize+1))
|
||||
if err != nil {
|
||||
th.Logf("[WARN] telegram avatar read failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
if int64(len(body)) > maxTelegramAvatarSize {
|
||||
th.Logf("[WARN] telegram avatar dropped: body exceeds %d bytes", maxTelegramAvatarSize)
|
||||
return ""
|
||||
}
|
||||
picture, err := saver.PutContent(userID, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
th.Logf("[WARN] telegram avatar save failed: %v", err)
|
||||
return ""
|
||||
}
|
||||
return picture
|
||||
}
|
||||
|
||||
const maxTelegramAvatarSize = 10 << 20
|
||||
|
||||
// botTokenInURLPath matches the bot-token segment of a Telegram URL anchored
|
||||
// between path slashes ("/botTOKEN/..."). The leading and trailing slashes
|
||||
// avoid matching unrelated identifiers that happen to start with "bot" (e.g.
|
||||
// the username "botFather" appearing elsewhere in a log line). Replacement
|
||||
// preserves the slashes via "/bot<redacted>/" to keep surrounding URL
|
||||
// structure intact for diagnostics.
|
||||
var botTokenInURLPath = regexp.MustCompile(`/bot[A-Za-z0-9:_-]+/`)
|
||||
|
||||
// redactBotURLInErr returns the error with any embedded Telegram bot-token
|
||||
// segment in URL paths replaced by "bot<redacted>". net/http's *url.Error
|
||||
// stringifies as `Op "URL": Err`, so a transport failure on a URL like
|
||||
// https://api.telegram.org/file/bot<TOKEN>/... otherwise prints the token
|
||||
// verbatim.
|
||||
func redactBotURLInErr(err error) error {
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
redacted := botTokenInURLPath.ReplaceAllString(err.Error(), "/bot<redacted>/")
|
||||
if redacted == err.Error() {
|
||||
return err
|
||||
}
|
||||
return errors.New(redacted)
|
||||
}
|
||||
|
||||
+165
-1
@@ -3,10 +3,13 @@ package provider
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha1"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/go-pkgz/rest"
|
||||
@@ -19,6 +22,18 @@ import (
|
||||
|
||||
// VerifyHandler implements non-oauth2 provider authorizing users with some confirmation.
|
||||
// can be email, IM or anything else implementing Sender interface
|
||||
//
|
||||
// Identity caveat: the local user id returned to the application is derived
|
||||
// from the verified address (ProviderName + "_" + HashID(address)). The
|
||||
// confirmation round-trip proves current control of the address at login
|
||||
// time; it does not guarantee a stable+unique identity over time. The owner
|
||||
// of an address can change without the address changing — employer
|
||||
// offboarding, lapsed free-mail accounts, and recycled domains all hand
|
||||
// control of an address to the next person who claims it. Integrators that
|
||||
// need stable identity should map the verified address to a server-side
|
||||
// immutable user id at first successful verify and key their records on
|
||||
// that id, not on the value returned here. See the "Email-as-identity
|
||||
// caveat" section of the README for guidance.
|
||||
type VerifyHandler struct {
|
||||
logger.L
|
||||
ProviderName string
|
||||
@@ -38,6 +53,112 @@ type VerifyHandler struct {
|
||||
// here. Nil disables validation and preserves legacy permissive
|
||||
// behavior — any non-empty "from" value is honored.
|
||||
AllowedRedirectHosts token.AllowedHosts
|
||||
|
||||
// ConfirmationStore enforces one-shot consumption of confirmation tokens.
|
||||
// When non-nil, a token cannot be redeemed twice within its TTL window.
|
||||
// Leave nil to keep the legacy behavior (token replayable until expiry).
|
||||
ConfirmationStore VerifConfirmationStore
|
||||
}
|
||||
|
||||
// VerifConfirmationStore tracks consumed confirmation tokens to prevent replay.
|
||||
// Implementations must be safe for concurrent use.
|
||||
type VerifConfirmationStore interface {
|
||||
// MarkUsed records key as consumed and returns alreadyUsed=true if it was
|
||||
// already recorded. The implementation MUST retain the marker for at
|
||||
// least the supplied ttl, or return a non-nil err if it cannot --
|
||||
// dropping a marker before its ttl while the underlying JWT is still
|
||||
// valid reopens the replay window the store is meant to close. err
|
||||
// signals a backend failure (network, disk, capacity, etc.); callers
|
||||
// MUST treat a non-nil err as fail-closed (reject the redemption).
|
||||
//
|
||||
// Adapter authors: do NOT embed key (or any caller-supplied data) in
|
||||
// returned errors. The handler logs err on the fail-closed branch, and
|
||||
// although key is the SHA-256 of the raw token rather than the token
|
||||
// itself, it still uniquely identifies the live, unredeemed JWT in
|
||||
// log destinations. Wrap the underlying backend error with a generic
|
||||
// description (e.g. "redis SET failed: %w") instead.
|
||||
MarkUsed(key string, ttl time.Duration) (alreadyUsed bool, err error)
|
||||
}
|
||||
|
||||
// VerifConfirmationStoreFunc is an adapter to use ordinary functions as
|
||||
// VerifConfirmationStore, mirroring the SenderFunc / token.AllowedHostsFunc
|
||||
// house pattern for closure-based config.
|
||||
type VerifConfirmationStoreFunc func(key string, ttl time.Duration) (alreadyUsed bool, err error)
|
||||
|
||||
// MarkUsed calls f(key, ttl) to implement VerifConfirmationStore.
|
||||
func (f VerifConfirmationStoreFunc) MarkUsed(key string, ttl time.Duration) (bool, error) {
|
||||
return f(key, ttl)
|
||||
}
|
||||
|
||||
// NewInMemoryVerifStore returns a process-local default VerifConfirmationStore.
|
||||
// Suitable for single-instance deployments. Multi-instance deployments behind
|
||||
// a load balancer MUST supply a shared backend (e.g. Redis) -- otherwise an
|
||||
// attacker who lands on a different instance from the legitimate user can
|
||||
// replay the token there. The default's failure is silent: the request
|
||||
// completes normally and no log indicates the protection was bypassed.
|
||||
func NewInMemoryVerifStore() VerifConfirmationStore {
|
||||
return &inMemoryVerifStore{used: make(map[string]time.Time)}
|
||||
}
|
||||
|
||||
type inMemoryVerifStore struct {
|
||||
mu sync.Mutex
|
||||
used map[string]time.Time // key -> expiry
|
||||
insertCount int
|
||||
}
|
||||
|
||||
// inMemoryVerifStoreSweepEvery is the in-memory store's amortization cadence.
|
||||
// Walking the whole map on every redemption is O(n) under a single mutex,
|
||||
// which serializes the hot path. Sweeping every N inserts keeps the map size
|
||||
// bounded by ~N + (concurrent redemptions during the gap) without holding
|
||||
// the lock through a full walk on most calls. Declared as a var rather than
|
||||
// a const so tests can lower it to exercise the sweep branch.
|
||||
var inMemoryVerifStoreSweepEvery = 256
|
||||
|
||||
func (s *inMemoryVerifStore) MarkUsed(key string, ttl time.Duration) (bool, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
now := time.Now()
|
||||
if exp, ok := s.used[key]; ok && exp.After(now) {
|
||||
return true, nil
|
||||
}
|
||||
// amortized eviction: walk the map only every Nth insert, not on every
|
||||
// hot-path call. The lookup above already rejects unexpired duplicates,
|
||||
// so worst-case staleness is bounded by N inserts between sweeps.
|
||||
s.insertCount++
|
||||
if s.insertCount >= inMemoryVerifStoreSweepEvery {
|
||||
s.insertCount = 0
|
||||
for k, exp := range s.used {
|
||||
if !exp.After(now) {
|
||||
delete(s.used, k)
|
||||
}
|
||||
}
|
||||
}
|
||||
s.used[key] = now.Add(ttl)
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// confirmationKey hashes the raw token so the store key length is bounded
|
||||
// regardless of token size, and so the in-memory map doesn't retain the
|
||||
// signed token itself.
|
||||
func confirmationKey(rawToken string) string {
|
||||
sum := sha256.Sum256([]byte(rawToken))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
|
||||
// scrubTokenFromRequest returns a shallow clone of r with the "token" query
|
||||
// parameter replaced by "<redacted>". rest.SendErrorJSON logs r.URL, and the
|
||||
// fail-closed branches in LoginHandler fire while the confirmation JWT is
|
||||
// still live (store didn't record consumption) -- a single log line equals
|
||||
// an unredeemed magic link without this scrub.
|
||||
func scrubTokenFromRequest(r *http.Request) *http.Request {
|
||||
if r == nil || r.URL == nil || r.URL.Query().Get("token") == "" {
|
||||
return r
|
||||
}
|
||||
rc := r.Clone(r.Context())
|
||||
q := rc.URL.Query()
|
||||
q.Set("token", "<redacted>")
|
||||
rc.URL.RawQuery = q.Encode()
|
||||
return rc
|
||||
}
|
||||
|
||||
// Sender defines interface to send emails
|
||||
@@ -66,7 +187,13 @@ type VerifTokenService interface {
|
||||
func (e VerifyHandler) Name() string { return e.ProviderName }
|
||||
|
||||
// LoginHandler gets name and address from query, makes confirmation token and sends it to user.
|
||||
// In case if confirmation token presented in the query uses it to create auth token
|
||||
// In case if confirmation token presented in the query uses it to create auth token.
|
||||
//
|
||||
// Consumption is final when ConfirmationStore is configured: the token is
|
||||
// marked used before any further side effects (avatar fetch, token issuance),
|
||||
// so a transient downstream failure burns the token and the user must request
|
||||
// a new confirmation email rather than retry the same link. This trade-off
|
||||
// keeps the replay check atomic with the security boundary.
|
||||
func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
// GET /login?site=site&user=name&address=someone@example.com
|
||||
@@ -89,6 +216,36 @@ func (e VerifyHandler) LoginHandler(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
store := e.ConfirmationStore
|
||||
// guard against a typed-nil VerifConfirmationStoreFunc: a non-nil
|
||||
// interface wrapping a nil func survives the != nil check above and
|
||||
// would panic at MarkUsed. Treat it as no store configured. Mirrors
|
||||
// the AllowedHostsFunc nil-guard in token/jwt.go.
|
||||
if fn, ok := store.(VerifConfirmationStoreFunc); ok && fn == nil {
|
||||
store = nil
|
||||
}
|
||||
if store != nil {
|
||||
ttl := time.Minute
|
||||
if confClaims.ExpiresAt != nil {
|
||||
if remaining := time.Until(confClaims.ExpiresAt.Time); remaining > 0 {
|
||||
ttl = remaining
|
||||
}
|
||||
}
|
||||
alreadyUsed, markErr := store.MarkUsed(confirmationKey(tkn), ttl)
|
||||
if markErr != nil {
|
||||
// fail-closed: a backend outage must not let attackers replay
|
||||
// tokens. Reject with the token scrubbed from the logged URL,
|
||||
// since on this branch the store did NOT record consumption so
|
||||
// the JWT in the URL is still live.
|
||||
rest.SendErrorJSON(w, scrubTokenFromRequest(r), e.L, http.StatusForbidden, markErr, "confirmation token store unavailable")
|
||||
return
|
||||
}
|
||||
if alreadyUsed {
|
||||
rest.SendErrorJSON(w, scrubTokenFromRequest(r), e.L, http.StatusForbidden, fmt.Errorf("token already used"), "confirmation token already consumed")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
elems := strings.Split(confClaims.Handshake.ID, "::")
|
||||
if len(elems) != 2 {
|
||||
rest.SendErrorJSON(w, r, e.L, http.StatusBadRequest, fmt.Errorf("%s", confClaims.Handshake.ID), "invalid handshake token")
|
||||
@@ -162,6 +319,13 @@ func (e VerifyHandler) sendConfirmation(w http.ResponseWriter, r *http.Request)
|
||||
Handshake: &token.Handshake{
|
||||
State: "",
|
||||
ID: user + "::" + address,
|
||||
// without copying "from" here the redirect validator at the
|
||||
// other end has nothing to validate or to redirect to. The
|
||||
// docs (and #275) advertise ?from=<url> on the verify login
|
||||
// path, but the original sendConfirmation never put it on
|
||||
// the handshake JWT, so production verify flows could never
|
||||
// honor from at all.
|
||||
From: r.URL.Query().Get("from"),
|
||||
},
|
||||
SessionOnly: r.URL.Query().Get("session") != "" && r.URL.Query().Get("session") != "0",
|
||||
RegisteredClaims: jwt.RegisteredClaims{
|
||||
|
||||
+7
-7
@@ -28,16 +28,16 @@ type User struct {
|
||||
Audience string `json:"aud,omitempty"`
|
||||
|
||||
// set by client
|
||||
IP string `json:"ip,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Attributes map[string]interface{} `json:"attrs,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
IP string `json:"ip,omitempty"`
|
||||
Email string `json:"email,omitempty"`
|
||||
Attributes map[string]any `json:"attrs,omitempty"`
|
||||
Role string `json:"role,omitempty"`
|
||||
}
|
||||
|
||||
// SetBoolAttr sets boolean attribute
|
||||
func (u *User) SetBoolAttr(key string, val bool) {
|
||||
if u.Attributes == nil {
|
||||
u.Attributes = map[string]interface{}{}
|
||||
u.Attributes = map[string]any{}
|
||||
}
|
||||
u.Attributes[key] = val
|
||||
}
|
||||
@@ -45,7 +45,7 @@ func (u *User) SetBoolAttr(key string, val bool) {
|
||||
// SetStrAttr sets string attribute
|
||||
func (u *User) SetStrAttr(key, val string) {
|
||||
if u.Attributes == nil {
|
||||
u.Attributes = map[string]interface{}{}
|
||||
u.Attributes = map[string]any{}
|
||||
}
|
||||
u.Attributes[key] = val
|
||||
}
|
||||
@@ -100,7 +100,7 @@ func (u *User) SliceAttr(key string) []string {
|
||||
// SetSliceAttr sets slice attribute for given key
|
||||
func (u *User) SetSliceAttr(key string, val []string) {
|
||||
if u.Attributes == nil {
|
||||
u.Attributes = map[string]interface{}{}
|
||||
u.Attributes = map[string]any{}
|
||||
}
|
||||
u.Attributes[key] = val
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user