Close all 14 findings from the codebase audit (1 high, 5 medium, 8 low): - Locking: replace the global API and workspace mutexes with per-instance and per-user keyed locks (new internal/api/locks.go) so a slow restore, upgrade or transfer no longer blocks unrelated users or instances; move Docker I/O out of the build queue and invalidate concurrent image-index observations with a generation counter; serialize instance deletion behind in-flight creation. - Security: equalize login timing for unknown usernames, periodically purge expired sessions/refresh tokens/lock rows, URL-encode orphan IDs in the frontend, upgrade vitest 3.x->5.x to clear 3 npm audit vulnerabilities (2 critical RCE). - Deployment: add a -healthcheck mode probing /healthz (used by Dockerfile HEALTHCHECK and compose), make ADMIN_PASSWORD optional in compose with removal guidance after first start, exclude test scripts from the image, and cap panel resources (cpus: 2, pids_limit: 512). - Reliability: check rows.Err() on admin listing loops, surface intent polling errors without sticking, disable close during in-flight create. Verified: go build/vet, full go test -race -count=1 ./..., frontend tsc build + 32 tests, npm audit 0, compose config, real-container healthcheck.
231 lines
6.8 KiB
Go
231 lines
6.8 KiB
Go
package api
|
|
|
|
import (
|
|
"context"
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"database/sql"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"log"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const accessLifetime = 5 * time.Minute
|
|
|
|
var errAuthentication = errors.New("authentication required")
|
|
|
|
func (a *API) initAuth() error {
|
|
if err := os.MkdirAll(a.root, 0700); err != nil {
|
|
return err
|
|
}
|
|
path := filepath.Join(a.root, "auth-signing.key")
|
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
|
|
if err == nil {
|
|
key := make([]byte, 32)
|
|
if _, err = rand.Read(key); err == nil {
|
|
_, err = f.Write(key)
|
|
}
|
|
if err == nil {
|
|
err = f.Sync()
|
|
}
|
|
closeErr := f.Close()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if closeErr != nil {
|
|
return closeErr
|
|
}
|
|
} else if !os.IsExist(err) {
|
|
return err
|
|
}
|
|
info, err := os.Lstat(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !info.Mode().IsRegular() || info.Mode().Perm() != 0600 {
|
|
return errors.New("unsafe signing key permissions")
|
|
}
|
|
a.authKey, err = os.ReadFile(path)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(a.authKey) != 32 {
|
|
return errors.New("invalid signing key")
|
|
}
|
|
_, err = a.s.DB.Exec(`CREATE TABLE IF NOT EXISTS auth_refresh(sid TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE, token_hash TEXT NOT NULL);
|
|
CREATE TABLE IF NOT EXISTS auth_failures(username TEXT PRIMARY KEY, failures INTEGER NOT NULL, locked_until INTEGER NOT NULL);`)
|
|
return err
|
|
}
|
|
|
|
type accessClaims struct {
|
|
Subject string `json:"sub"`
|
|
Session string `json:"sid"`
|
|
Issuer string `json:"iss"`
|
|
Audience string `json:"aud"`
|
|
Expires int64 `json:"exp"`
|
|
}
|
|
|
|
const jwtHeader = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9"
|
|
|
|
func (a *API) accessJWT(sid, uid string, expires time.Time) string {
|
|
payload, _ := json.Marshal(accessClaims{uid, sid, "dsh-panel", "dsh-panel", expires.Unix()})
|
|
body := jwtHeader + "." + base64.RawURLEncoding.EncodeToString(payload)
|
|
mac := hmac.New(sha256.New, a.authKey)
|
|
mac.Write([]byte(body))
|
|
return body + "." + base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
func (a *API) verifyAccess(r *http.Request, sid, uid string) error {
|
|
if a.authErr != nil || len(a.authKey) != 32 {
|
|
return errAuthentication
|
|
}
|
|
c, err := r.Cookie("dsh_access")
|
|
if err != nil {
|
|
return errAuthentication
|
|
}
|
|
p := strings.Split(c.Value, ".")
|
|
if len(p) != 3 || p[0] != jwtHeader {
|
|
return errAuthentication
|
|
}
|
|
sig, err := base64.RawURLEncoding.DecodeString(p[2])
|
|
if err != nil {
|
|
return errAuthentication
|
|
}
|
|
mac := hmac.New(sha256.New, a.authKey)
|
|
mac.Write([]byte(p[0] + "." + p[1]))
|
|
if !hmac.Equal(sig, mac.Sum(nil)) {
|
|
return errAuthentication
|
|
}
|
|
data, err := base64.RawURLEncoding.DecodeString(p[1])
|
|
if err != nil {
|
|
return errAuthentication
|
|
}
|
|
var claims accessClaims
|
|
if json.Unmarshal(data, &claims) != nil || claims.Subject != uid || claims.Session != sid || claims.Issuer != "dsh-panel" || claims.Audience != "dsh-panel" || claims.Expires <= time.Now().Unix() || claims.Expires > time.Now().Add(accessLifetime).Unix() {
|
|
return errAuthentication
|
|
}
|
|
return nil
|
|
}
|
|
func refreshToken() (string, error) {
|
|
b := make([]byte, 32)
|
|
_, err := rand.Read(b)
|
|
return base64.RawURLEncoding.EncodeToString(b), err
|
|
}
|
|
func refreshHash(token string) string {
|
|
h := sha256.Sum256([]byte(token))
|
|
return hex.EncodeToString(h[:])
|
|
}
|
|
func (a *API) secureCookies(r *http.Request) bool {
|
|
secure := r.TLS != nil
|
|
if u, err := url.Parse(a.origin); err == nil && u.Scheme == "https" {
|
|
secure = true
|
|
}
|
|
return secure
|
|
}
|
|
func (a *API) authCookies(w http.ResponseWriter, r *http.Request, sid, uid, csrf, refresh string) {
|
|
for _, c := range []*http.Cookie{
|
|
{Name: "dsh_session", Value: sid, Path: "/", HttpOnly: true},
|
|
{Name: "dsh_access", Value: a.accessJWT(sid, uid, time.Now().Add(accessLifetime)), Path: "/", HttpOnly: true, MaxAge: int(accessLifetime.Seconds())},
|
|
{Name: "dsh_refresh", Value: refresh, Path: "/api", HttpOnly: true},
|
|
{Name: "dsh_csrf", Value: csrf, Path: "/"},
|
|
} {
|
|
c.Secure = a.secureCookies(r)
|
|
c.SameSite = http.SameSiteLaxMode
|
|
http.SetCookie(w, c)
|
|
}
|
|
w.Header().Set("Cache-Control", "no-store")
|
|
}
|
|
func (a *API) refresh(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method != http.MethodPost {
|
|
fail(w, 405, "method not allowed")
|
|
return
|
|
}
|
|
if a.authErr != nil {
|
|
fail(w, 503, "authentication unavailable")
|
|
return
|
|
}
|
|
sid, err := r.Cookie("dsh_session")
|
|
if err != nil {
|
|
fail(w, 401, "authentication required")
|
|
return
|
|
}
|
|
u, csrf, err := a.s.SessionUser(sid.Value)
|
|
if err != nil {
|
|
fail(w, 401, "authentication required")
|
|
return
|
|
}
|
|
old, err := r.Cookie("dsh_refresh")
|
|
if err != nil {
|
|
fail(w, 401, "authentication required")
|
|
return
|
|
}
|
|
token, err := refreshToken()
|
|
if err != nil {
|
|
fail(w, 500, "session error")
|
|
return
|
|
}
|
|
result, err := a.s.DB.Exec("UPDATE auth_refresh SET token_hash=? WHERE sid=? AND token_hash=? AND EXISTS(SELECT 1 FROM sessions WHERE id=? AND expires>?)", refreshHash(token), sid.Value, refreshHash(old.Value), sid.Value, time.Now().Unix())
|
|
if err != nil {
|
|
fail(w, 500, "session error")
|
|
return
|
|
}
|
|
n, err := result.RowsAffected()
|
|
if err != nil || n != 1 {
|
|
fail(w, 401, "authentication required")
|
|
return
|
|
}
|
|
a.authCookies(w, r, sid.Value, u.ID, csrf, token)
|
|
writeJSON(w, map[string]any{"user": u, "csrf_token": csrf})
|
|
}
|
|
func (a *API) accountLocked(username string) (bool, error) {
|
|
var until int64
|
|
err := a.s.DB.QueryRow("SELECT locked_until FROM auth_failures WHERE username=?", username).Scan(&until)
|
|
if err == sql.ErrNoRows {
|
|
return false, nil
|
|
}
|
|
return until > time.Now().Unix(), err
|
|
}
|
|
|
|
// RunSessionCleanup removes expired sessions, their refresh tokens and elapsed
|
|
// login locks every ten minutes; without it the sessions table grows without
|
|
// bound because SessionUser only filters rows on read.
|
|
func (a *API) RunSessionCleanup(ctx context.Context) {
|
|
ticker := time.NewTicker(10 * time.Minute)
|
|
defer ticker.Stop()
|
|
for {
|
|
a.cleanupSessions(time.Now().Unix())
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (a *API) cleanupSessions(now int64) {
|
|
for _, q := range []struct{ name, sql string }{
|
|
{"refresh tokens", "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE expires<=?)"},
|
|
{"sessions", "DELETE FROM sessions WHERE expires<=?"},
|
|
{"login locks", "DELETE FROM auth_failures WHERE locked_until>0 AND locked_until<=?"},
|
|
} {
|
|
if _, err := a.s.DB.Exec(q.sql, now); err != nil {
|
|
log.Printf("session cleanup (%s): %v", q.name, err)
|
|
}
|
|
}
|
|
}
|
|
func (a *API) failedLogin(username string) error {
|
|
_, err := a.s.DB.Exec(`INSERT INTO auth_failures(username,failures,locked_until) VALUES(?,1,0)
|
|
ON CONFLICT(username) DO UPDATE SET
|
|
failures=CASE WHEN locked_until>0 AND locked_until<=? THEN 1 ELSE failures+1 END,
|
|
locked_until=CASE WHEN locked_until>0 AND locked_until<=? THEN 0 WHEN failures+1>=5 THEN ? ELSE 0 END`, username, time.Now().Unix(), time.Now().Unix(), time.Now().Add(15*time.Minute).Unix())
|
|
return err
|
|
}
|