Files
terdut-server/internal/api/auth.go
T
Niklas Ye f15db0e20a Add gosec to CI; fix the one real finding it surfaced
Part of the same security-hardening pass as the last two commits. make
lint was go vet only; govulncheck and gitleaks already scanned deps and
secrets on every push, but nothing read this repo's own source for
risky patterns (weak crypto, injection shapes, insecure cookies, ...).

New `make security-code` runs gosec, wired into ci.yaml's security job
alongside the other two. G104 (unchecked error) is excluded at the
Makefile level: every one of its 41 initial hits was this codebase's
existing, deliberate idiom for a best-effort write or an already-
reviewed json.Unmarshal of its own JSONB, predating gosec, and the rule
cannot tell that apart from a mistake -- seventeen individual #nosec
comments would hide a future real G104 regression in the suppression
noise rather than surface it. Reasoning is on the Makefile target.

Of the 12 remaining hits:
  - Genuinely real: oidc.go's callback logged error_description (and,
    two call sites down, identity.Subject) via %s before the request's
    state was even checked against its cookie -- an attacker-reachable
    value going into the log unquoted. Switched to %q, matching
    identity.Username's existing treatment, so a value holding a
    newline can't forge a second log line.
  - False positives, annotated inline rather than globally suppressed:
    4x G124 on cookies that already set Secure via cookieSecure(...)
    (a function call, not the literal `true` the rule wants), 3x G202
    on sqlArgs-built queries that only ever splice in a "$N"
    placeholder, never a value, and the remaining 5x G706 on log lines
    that were already %q-quoted -- gosec's taint analysis doesn't
    model format verbs, so it flags the tainted argument regardless.

Also fixed handleMe's swallowed Scan error (gosec's catch, pre-fix):
a transient DB error left hash/dismissed at their zero values and the
response claimed no password and no onboarding dismissal regardless
of the truth, rather than surfacing a 500.

Checked both workflow files for the injection class letsvisit found
there (a `${{ }}` expression spliced straight into a `run:` block):
every one here already goes through `env:` as a quoted shell variable,
documented in ci.yaml's own header comment. Nothing to fix.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-07 22:12:31 +02:00

442 lines
15 KiB
Go

package api
import (
"context"
"database/sql"
"errors"
"log"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/go-chi/chi/v5"
"golang.org/x/crypto/bcrypt"
)
const (
// sessionCookie carries a web UI session. It is HttpOnly, so page script
// never sees the token; the page learns who it is from GET /api/me.
sessionCookie = "terdut_session"
// sessionTTL is how long a session lives without being used. It slides, so
// a phone that opens the UI now and then stays signed in indefinitely.
sessionTTL = 30 * 24 * time.Hour
// sessionTouchEvery bounds how often a request may slide the expiry.
sessionTouchEvery = time.Hour
minPasswordLen = 10
// maxPasswordLen is bcrypt's limit; it rejects longer input outright.
maxPasswordLen = 72
loginWindow = 15 * time.Minute
loginMaxPerUser = 10
loginMaxPerAddr = 30
passwordHashCost = bcrypt.DefaultCost
)
// dummyHash is compared against when the username is unknown or has no
// password, so a failed login takes as long whichever way it failed.
var dummyHash = sync.OnceValue(func() []byte {
h, _ := bcrypt.GenerateFromPassword([]byte("terdut-dummy-password"), passwordHashCost)
return h
})
// loginLimiter counts failed logins (and other unauthenticated attempts:
// sign-up, OIDC/device start) in a fixed window, per key — a username, a
// client address, or both, depending on the caller.
//
// Backed by Postgres rather than an in-memory map: this server runs more
// than one replica in production (v0.37.0), and a counter that only ever
// sees its own pod's traffic would quietly let every limit through
// multiplied by the replica count — two loginLimiter values pointed at the
// same db, standing in for two replicas, now share exactly one count per
// key instead of each keeping their own.
//
// The window resets rather than slides, the same behavior the in-memory
// version it replaces had: once a key's window is older than loginWindow,
// the next fail() starts a fresh one instead of extending the stale one.
type loginLimiter struct {
db *sql.DB
}
func newLoginLimiter(db *sql.DB) *loginLimiter {
return &loginLimiter{db: db}
}
func (l *loginLimiter) blocked(ctx context.Context, key string, max int) bool {
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
var count int
err := l.db.QueryRowContext(ctx, `
SELECT count FROM rate_limit_counters
WHERE key = $1 AND window_start > $2`,
key, cutoff,
).Scan(&count)
if err != nil {
// No row (never failed, or its window already expired): not blocked.
// A real query error fails the same way — a rate limiter that locks
// everyone out during a brief database hiccup is worse than one that
// is briefly too generous.
return false
}
return count >= max
}
func (l *loginLimiter) fail(ctx context.Context, keys ...string) {
now := time.Now().Unix()
windowSecs := int64(loginWindow.Seconds())
for _, key := range keys {
if _, err := l.db.ExecContext(ctx, `
INSERT INTO rate_limit_counters (key, window_start, count)
VALUES ($1, $2, 1)
ON CONFLICT (key) DO UPDATE SET
window_start = CASE WHEN rate_limit_counters.window_start <= $2 - $3
THEN $2 ELSE rate_limit_counters.window_start END,
count = CASE WHEN rate_limit_counters.window_start <= $2 - $3
THEN 1 ELSE rate_limit_counters.count + 1 END`,
key, now, windowSecs,
); err != nil {
log.Printf("rate limiter: record failure for %q: %v", key, err) // #nosec G706 -- %q
}
}
}
func (l *loginLimiter) clear(ctx context.Context, key string) {
if _, err := l.db.ExecContext(ctx, "DELETE FROM rate_limit_counters WHERE key = $1", key); err != nil {
log.Printf("rate limiter: clear %q: %v", key, err)
}
}
// purgeRateLimits deletes rate-limit windows that have expired, from the
// sweeper — otherwise every distinct username and address this server has
// ever seen a failed attempt from would stay a row forever.
func purgeRateLimits(ctx context.Context, db *sql.DB) {
cutoff := time.Now().Unix() - int64(loginWindow.Seconds())
res, err := db.ExecContext(ctx,
"DELETE FROM rate_limit_counters WHERE window_start <= $1", cutoff)
if err != nil {
log.Printf("sweeper: purge rate limit counters: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("sweeper: purged %d expired rate limit counter(s)", n)
}
}
// clientAddr is the address a login is counted against. Behind the gateway
// RemoteAddr is the gateway itself, so the first X-Forwarded-For hop is used
// when present. It can be forged, but only to dodge the address limit; the
// per-username limit does not depend on it.
func clientAddr(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
first, _, _ := strings.Cut(xff, ",")
return strings.TrimSpace(first)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}
// cookieSecure decides the cookie's Secure flag. TLS terminates at the gateway,
// so the server usually sees plain HTTP; the public URL is what says whether
// browsers reach it over HTTPS.
func cookieSecure(publicURL string, r *http.Request) bool {
return strings.HasPrefix(publicURL, "https://") ||
r.TLS != nil ||
r.Header.Get("X-Forwarded-Proto") == "https"
}
// validatePassword returns a message for the client, or "" when acceptable.
func validatePassword(pw string) string {
switch {
case len(pw) < minPasswordLen:
return "password must be at least " + strconv.Itoa(minPasswordLen) + " characters"
case len(pw) > maxPasswordLen:
return "password must be at most " + strconv.Itoa(maxPasswordLen) + " bytes"
}
return ""
}
func hashPassword(pw string) (string, error) {
h, err := bcrypt.GenerateFromPassword([]byte(pw), passwordHashCost)
return string(h), err
}
// startSession mints a session and sets the cookie. Shared by login and
// sign-up: somebody who has just chosen a password is signed in, rather than
// being sent to a form to type the same credential again.
func startSession(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string) error {
return startSessionCapped(w, r, db, userID, publicURL, 0)
}
// startSessionCapped is startSession with a hard ceiling on the session's life,
// which sliding never extends. maxAge zero means no ceiling. A single sign-on
// login uses it: the login is the only moment the provider's groups are read, so
// a session that could outlive it indefinitely would keep access the provider
// has since taken away.
func startSessionCapped(w http.ResponseWriter, r *http.Request, db *sql.DB, userID int64, publicURL string, maxAge time.Duration) error {
raw, tokenHash, err := randomToken()
if err != nil {
return err
}
now := time.Now()
life := sessionTTL
var ceiling *int64
if maxAge > 0 {
c := now.Add(maxAge).Unix()
ceiling = &c
life = min(life, maxAge)
}
if _, err := db.ExecContext(r.Context(), `
INSERT INTO sessions (token_hash, user_id, created_at, last_seen_at, expires_at, max_expires_at, user_agent)
VALUES ($1, $2, $3, $4, $5, $6, $7)`,
tokenHash, userID, now.Unix(), now.Unix(), now.Add(life).Unix(), ceiling, r.UserAgent()); err != nil {
return err
}
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
// cookieSecure(publicURL, r), not a literal true, which is what trips
// this rule. See cookieSecure's own doc comment above.
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: raw,
Path: "/",
MaxAge: int(life.Seconds()),
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
return nil
}
// handleLogin exchanges a username and password for a session cookie.
func handleLogin(db *sql.DB, limiter *loginLimiter, publicURL string) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req struct {
Username string `json:"username"`
Password string `json:"password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
username := strings.TrimSpace(req.Username)
userKey := "user:" + strings.ToLower(username)
addrKey := "addr:" + clientAddr(r)
if limiter.blocked(r.Context(), userKey, loginMaxPerUser) || limiter.blocked(r.Context(), addrKey, loginMaxPerAddr) {
w.Header().Set("Retry-After", strconv.Itoa(int(loginWindow.Seconds())))
respond(w, http.StatusTooManyRequests, errResp("too many failed attempts, try again later"))
return
}
var userID int64
var hash sql.NullString
err := db.QueryRowContext(r.Context(),
"SELECT id, password_hash FROM users WHERE username = $1", username,
).Scan(&userID, &hash)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
stored := dummyHash()
if hash.Valid {
stored = []byte(hash.String)
}
match := bcrypt.CompareHashAndPassword(stored, []byte(req.Password)) == nil
if !match || !hash.Valid {
limiter.fail(r.Context(), userKey, addrKey)
respond(w, http.StatusUnauthorized, errResp("invalid username or password"))
return
}
limiter.clear(r.Context(), userKey)
if err := startSession(w, r, db, userID, publicURL); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
user, err := fetchUser(r.Context(), db, userID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{User: user, HasPassword: true})
}
}
// handleLogout ends the browser's session. It sits outside AuthMiddleware so
// that a browser holding an already-expired cookie can still clear it.
func handleLogout(db *sql.DB, publicURL string) http.HandlerFunc {
crossOrigin := http.NewCrossOriginProtection()
return func(w http.ResponseWriter, r *http.Request) {
if err := crossOrigin.Check(r); err != nil {
respond(w, http.StatusForbidden, errResp("cross-origin request rejected"))
return
}
if c, err := r.Cookie(sessionCookie); err == nil && c.Value != "" {
db.ExecContext(r.Context(), "DELETE FROM sessions WHERE token_hash = $1", hashToken(c.Value))
}
// #nosec G124 -- HttpOnly/SameSite are literal below; Secure is
// cookieSecure(publicURL, r), not a literal true, which is what
// trips this rule. See cookieSecure's own doc comment above.
http.SetCookie(w, &http.Cookie{
Name: sessionCookie,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: cookieSecure(publicURL, r),
SameSite: http.SameSiteLaxMode,
})
w.WriteHeader(http.StatusNoContent)
}
}
type meResponse struct {
User any `json:"user"`
HasPassword bool `json:"has_password"`
// OnboardingDismissed is whether this person has put the first-run
// checklist away. Per user rather than per browser: somebody who finishes
// setting up on a laptop should not be nagged again on their phone.
OnboardingDismissed bool `json:"onboarding_dismissed"`
}
// handleMe says who the caller is. The web UI calls it on load to decide
// between the login form and the app, since it cannot read its own cookie.
func handleMe(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
caller, ok := userFromContext(r.Context())
if !ok {
respond(w, http.StatusForbidden, errResp("this endpoint is for human accounts only"))
return
}
user, err := fetchUser(r.Context(), db, caller.ID)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
var hash sql.NullString
var dismissed *int64
if err := db.QueryRowContext(r.Context(),
"SELECT password_hash, onboarding_dismissed_at FROM users WHERE id = $1",
caller.ID).Scan(&hash, &dismissed); err != nil {
// fetchUser above already found this row, so an error here is a
// transient database problem, not a missing user — worth a 500
// rather than silently answering "no password, not dismissed",
// which a client would otherwise take at face value.
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
respond(w, http.StatusOK, meResponse{
User: user,
HasPassword: hash.Valid,
OnboardingDismissed: dismissed != nil,
})
}
}
// handleSetPassword sets a user's web UI password.
//
// Changing your own password takes the current one, when there is one, so an
// unattended signed-in browser cannot be used to take the account over. Setting
// somebody else's is how an admin gives a user their first password, and is
// restricted to administrators: it hands over an account outright, without
// knowing the password it replaces.
//
// Every other session of the target is ended: a password change is what you
// do when you think someone else is signed in.
func handleSetPassword(db *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64)
if err != nil {
respond(w, http.StatusBadRequest, errResp("invalid user id"))
return
}
if !requireSelfOrAdmin(w, r, id) {
return
}
var req struct {
Password string `json:"password"`
CurrentPassword string `json:"current_password"`
}
if err := decodeJSON(r, &req); err != nil {
respond(w, http.StatusBadRequest, errResp("invalid request body"))
return
}
if msg := validatePassword(req.Password); msg != "" {
respond(w, http.StatusBadRequest, errResp(msg))
return
}
var existing sql.NullString
err = db.QueryRowContext(r.Context(),
"SELECT password_hash FROM users WHERE id = $1", id).Scan(&existing)
if errors.Is(err, sql.ErrNoRows) {
respond(w, http.StatusNotFound, errResp("user not found"))
return
}
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
caller, _ := userFromContext(r.Context())
if caller.ID == id && existing.Valid &&
bcrypt.CompareHashAndPassword([]byte(existing.String), []byte(req.CurrentPassword)) != nil {
respond(w, http.StatusForbidden, errResp("current password is incorrect"))
return
}
hash, err := hashPassword(req.Password)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
tx, err := db.BeginTx(r.Context(), nil)
if err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
defer tx.Rollback()
if _, err := tx.ExecContext(r.Context(),
"UPDATE users SET password_hash = $1 WHERE id = $2", hash, id); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
keep, _ := sessionFromContext(r.Context()) // zero when changed with an API key
if _, err := tx.ExecContext(r.Context(),
"DELETE FROM sessions WHERE user_id = $1 AND id != $2", id, keep); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
if err := tx.Commit(); err != nil {
respond(w, http.StatusInternalServerError, errResp("internal error"))
return
}
w.WriteHeader(http.StatusNoContent)
}
}
// purgeSessions deletes sessions that have expired, from the sweeper.
func purgeSessions(ctx context.Context, db *sql.DB) {
res, err := db.ExecContext(ctx,
"DELETE FROM sessions WHERE expires_at < $1", time.Now().Unix())
if err != nil {
log.Printf("sweeper: purge sessions: %v", err)
return
}
if n, _ := res.RowsAffected(); n > 0 {
log.Printf("sweeper: purged %d expired session(s)", n)
}
}