63701dd086
Addresses the gaps identified in the last audit. Restore (was a stub returning "not yet implemented"). Every repository shares one connection pool, so the database cannot be swapped underneath a live server. Restore is therefore two-phase: RestoreBackup validates the file and stages it beside the database; db.New applies it before the pool is opened, which is the only safe moment. The database being replaced is preserved as <db>.replaced-<timestamp>, and stale -wal/-shm are removed so SQLite cannot replay the old journal over the restored file. Validation is strict — SQLite integrity_check plus a schema probe — because applying an unrelated file would destroy the install. GET/DELETE /api/v1/backups/restore inspect and cancel a staged restore. The CLI does both phases at once, since it runs standalone; `orchestrad backup` was also a stub and now works. Secret key. With nothing configured the key is generated once and persisted to <data>/secret.key, so restarts reuse it and moving the stack to another server is a matter of copying the data directory. Upgrades are handled: if a database already exists the install was silently running on the legacy built-in default, so that value is adopted and written out rather than replaced — generating a fresh key there would make every stored credential undecryptable. The file is owner-only (ACL-restricted on Windows). Multi-arch image: buildx now emits linux/amd64 + linux/arm64, matching the architectures the release binaries already covered. The Dockerfile cross-compiles via TARGETARCH rather than emulating, so arm64 costs little. CSRF: the middleware previously checked only that a header was *present* and was never wired up, and /auth/csrf returned "csrf-token-placeholder". Tokens are now nonce + HMAC-SHA256 signed with the application secret, validated properly, and the middleware is mounted on /api/v1. Bearer and API-key requests are not CSRF-reachable and pass through untouched, so this is transparent to the SPA and to API clients. Also: the Windows store import drops CRYPT_EXPORTABLE (the store copy is not the source of truth — <data>/tls holds the key, so portability is unaffected and a non-exportable server key is the better posture), the PFX password is written to server.pfx.password beside the bundle so an operator importing it by hand does not have to hunt for a password they never chose, and the "renewed" log line now reflects whether a leaf was actually issued instead of guessing from its age. Verified live: backup -> stage -> restart applies and preserves the previous database; secret key generated, adopted, and read back across restarts with the credential check confirming decryptability; CSRF endpoint issues real signed tokens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
100 lines
3.2 KiB
Go
100 lines
3.2 KiB
Go
// Package api - CSRF protection.
|
|
//
|
|
// CSRF only matters for credentials the browser attaches automatically. The SPA
|
|
// authenticates with a bearer token it holds in localStorage and sets on each
|
|
// request, and API clients send X-API-Key — neither is ambient, so neither is
|
|
// forgeable cross-site, and both skip these checks. What the middleware guards
|
|
// is cookie-authenticated mutation.
|
|
//
|
|
// Tokens are stateless and signed: <nonce>.<base64 HMAC-SHA256(nonce)> keyed by
|
|
// the application secret. That means no server-side store to expire or
|
|
// replicate, while a token still cannot be minted by an attacker who cannot
|
|
// read the secret.
|
|
package api
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"crypto/subtle"
|
|
"encoding/base64"
|
|
"fmt"
|
|
"net/http"
|
|
"strings"
|
|
)
|
|
|
|
// CSRF issues and validates CSRF tokens.
|
|
type CSRF struct {
|
|
secret []byte
|
|
}
|
|
|
|
// NewCSRF creates a CSRF issuer/validator keyed by the application secret.
|
|
func NewCSRF(secret []byte) *CSRF {
|
|
return &CSRF{secret: secret}
|
|
}
|
|
|
|
// IssueToken returns a fresh signed token.
|
|
func (c *CSRF) IssueToken() (string, error) {
|
|
nonce := make([]byte, 16)
|
|
if _, err := rand.Read(nonce); err != nil {
|
|
return "", fmt.Errorf("generating csrf nonce: %w", err)
|
|
}
|
|
n := base64.RawURLEncoding.EncodeToString(nonce)
|
|
return n + "." + c.sign(n), nil
|
|
}
|
|
|
|
// ValidToken reports whether token was issued by this server.
|
|
func (c *CSRF) ValidToken(token string) bool {
|
|
nonce, sig, ok := strings.Cut(token, ".")
|
|
if !ok || nonce == "" || sig == "" {
|
|
return false
|
|
}
|
|
return subtle.ConstantTimeCompare([]byte(sig), []byte(c.sign(nonce))) == 1
|
|
}
|
|
|
|
func (c *CSRF) sign(nonce string) string {
|
|
mac := hmac.New(sha256.New, c.secret)
|
|
mac.Write([]byte(nonce))
|
|
return base64.RawURLEncoding.EncodeToString(mac.Sum(nil))
|
|
}
|
|
|
|
// Handler serves GET /api/v1/auth/csrf.
|
|
func (c *CSRF) Handler(w http.ResponseWriter, r *http.Request) {
|
|
token, err := c.IssueToken()
|
|
if err != nil {
|
|
WriteError(w, http.StatusInternalServerError, ErrCodeInternalError, "Could not issue a CSRF token")
|
|
return
|
|
}
|
|
WriteJSON(w, http.StatusOK, map[string]string{"token": token})
|
|
}
|
|
|
|
// Middleware rejects cookie-authenticated mutating requests that do not carry a
|
|
// valid X-CSRF-Token. Safe methods pass, and so do requests that authenticate
|
|
// with an explicit Authorization/X-API-Key header, which CSRF cannot forge.
|
|
func (c *CSRF) Middleware(next http.Handler) http.Handler {
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if isReadMethod(r.Method) {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// Explicit credentials are not attached by the browser on a cross-site
|
|
// request, so these are not CSRF-reachable.
|
|
if r.Header.Get("X-API-Key") != "" || strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
// No ambient credential either: nothing to protect. Let it through so
|
|
// the auth middleware produces the 401.
|
|
if _, err := r.Cookie(DocsCookieName); err != nil {
|
|
next.ServeHTTP(w, r)
|
|
return
|
|
}
|
|
if !c.ValidToken(r.Header.Get("X-CSRF-Token")) {
|
|
WriteError(w, http.StatusForbidden, ErrCodeForbidden,
|
|
"A valid X-CSRF-Token header is required for cookie-authenticated requests (get one from GET /api/v1/auth/csrf)")
|
|
return
|
|
}
|
|
next.ServeHTTP(w, r)
|
|
})
|
|
}
|