Files
dsh-web/internal/api/admin.go
T
m1saka 66abb76318 Fix audit findings: lock architecture, security, deployment hardening
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.
2026-10-13 20:59:28 +08:00

221 lines
6.0 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import (
"database/sql"
"encoding/json"
"git.misaka.ren/m1saka/dsh-web/internal/store"
"golang.org/x/crypto/bcrypt"
"io"
"net/http"
"os"
"regexp"
"strconv"
"time"
)
var usernamePattern = regexp.MustCompile("^[a-zA-Z0-9_-]{3,40}$")
func DefaultQuota() int64 {
if n, err := strconv.ParseInt(os.Getenv("DEFAULT_QUOTA_BYTES"), 10, 64); err == nil && n > 0 {
return n
}
return 10 << 30
}
func (a *API) users(w http.ResponseWriter, r *http.Request) {
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
switch r.Method {
case "GET":
rows, err := a.s.DB.Query("SELECT id,username,admin,quota,created FROM users ORDER BY created")
if err != nil {
fail(w, 500, "database error")
return
}
defer rows.Close()
out := []store.User{}
for rows.Next() {
var v store.User
var created int64
if err := rows.Scan(&v.ID, &v.Username, &v.Admin, &v.Quota, &created); err != nil {
fail(w, 500, "database error")
return
}
v.Created = time.Unix(created, 0)
out = append(out, v)
}
if err := rows.Err(); err != nil {
fail(w, 500, "database error")
return
}
writeJSON(w, out)
case "POST":
var in struct {
Username string
Password string
Admin bool
Quota int64 `json:"quota_bytes"`
}
if json.NewDecoder(r.Body).Decode(&in) != nil || !usernamePattern.MatchString(in.Username) || len(in.Password) < 12 || len(in.Password) > 72 || in.Quota < 0 {
fail(w, 400, "username must be 3–40 letters/digits/_/-, password 12–72 bytes, quota nonnegative")
return
}
if in.Quota == 0 {
in.Quota = DefaultQuota()
}
h, e := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if e != nil {
fail(w, 500, "password error")
return
}
v := store.User{ID: store.ID(), Username: in.Username, Admin: in.Admin, Quota: in.Quota, Created: time.Now()}
if e = a.s.CreateUser(v, string(h)); e != nil {
fail(w, 409, "username already exists or database unavailable")
return
}
a.s.AddAudit(u.ID, "user.create", v.ID, "")
writeJSON(w, v)
case "PATCH":
var in struct {
ID string
Quota int64 `json:"quota_bytes"`
}
if json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096)).Decode(&in) != nil || in.ID == "" || in.Quota <= 0 {
fail(w, 400, "positive quota and user ID required")
return
}
result, err := a.s.DB.Exec("UPDATE users SET quota=? WHERE id=?", in.Quota, in.ID)
if err != nil {
fail(w, 500, "quota update failed")
return
}
count, _ := result.RowsAffected()
if count != 1 {
fail(w, 404, "user not found")
return
}
a.s.AddAudit(u.ID, "user.quota", in.ID, strconv.FormatInt(in.Quota, 10))
writeJSON(w, map[string]bool{"ok": true})
default:
fail(w, 405, "method not allowed")
}
}
func (a *API) resetPassword(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
if r.Method != http.MethodPost {
fail(w, 405, "method not allowed")
return
}
u, _, err := a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
var in struct{ ID, Password string }
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
if decoder.Decode(&in) != nil || decoder.Decode(new(any)) != io.EOF || in.ID == "" || len(in.Password) < 12 || len(in.Password) > 72 {
fail(w, 400, "user ID and password 12–72 bytes required")
return
}
hash, err := bcrypt.GenerateFromPassword([]byte(in.Password), bcrypt.DefaultCost)
if err != nil {
fail(w, 500, "password error")
return
}
// Login must not issue a session using a hash read before this reset.
a.authLoginMu.Lock()
defer a.authLoginMu.Unlock()
u, _, err = a.user(r)
if err != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
tx, err := a.s.DB.BeginTx(r.Context(), nil)
if err != nil {
fail(w, 500, "password reset failed")
return
}
defer tx.Rollback()
var username string
err = tx.QueryRowContext(r.Context(), "SELECT username FROM users WHERE id=?", in.ID).Scan(&username)
if err == sql.ErrNoRows {
fail(w, 404, "user not found")
return
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "UPDATE users SET pass_hash=? WHERE id=?", string(hash), in.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM auth_refresh WHERE sid IN (SELECT id FROM sessions WHERE uid=?)", in.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM sessions WHERE uid=?", in.ID)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "DELETE FROM auth_failures WHERE username=?", username)
}
if err == nil {
_, err = tx.ExecContext(r.Context(), "INSERT INTO audit(uid,action,target,detail,at) VALUES(?,?,?,?,?)", u.ID, "user.reset_password", in.ID, "", time.Now().Unix())
}
if err == nil {
err = tx.Commit()
}
if err != nil {
fail(w, 500, "password reset failed")
return
}
writeJSON(w, map[string]bool{"ok": true, "reauthenticate": u.ID == in.ID})
}
func (a *API) audit(w http.ResponseWriter, r *http.Request) {
u, _, e := a.user(r)
if e != nil {
fail(w, 401, "authentication required")
return
}
if !u.Admin {
fail(w, 403, "administrator required")
return
}
if r.Method != "GET" {
fail(w, 405, "method not allowed")
return
}
rows, e := a.s.DB.Query("SELECT id,uid,action,target,detail,at FROM audit ORDER BY id DESC LIMIT 500")
if e != nil {
fail(w, 500, "database error")
return
}
defer rows.Close()
out := []map[string]any{}
for rows.Next() {
var id, at int64
var uid, action, target, detail string
if rows.Scan(&id, &uid, &action, &target, &detail, &at) != nil {
fail(w, 500, "database error")
return
}
out = append(out, map[string]any{"id": id, "user_id": uid, "action": action, "target": target, "detail": detail, "created_at": time.Unix(at, 0)})
}
if err := rows.Err(); err != nil {
fail(w, 500, "database error")
return
}
writeJSON(w, out)
}