f15db0e20a
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>
442 lines
15 KiB
Go
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)
|
|
}
|
|
}
|