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>
105 lines
3.5 KiB
Go
105 lines
3.5 KiB
Go
package api
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
)
|
|
|
|
func TestCSRFTokenIssueAndValidate(t *testing.T) {
|
|
c := NewCSRF([]byte("a-test-secret"))
|
|
token, err := c.IssueToken()
|
|
if err != nil {
|
|
t.Fatalf("IssueToken: %v", err)
|
|
}
|
|
if !c.ValidToken(token) {
|
|
t.Error("a freshly issued token should validate")
|
|
}
|
|
|
|
// Tokens are unguessable and signed: tampering, forging, and tokens from a
|
|
// different secret must all fail.
|
|
for name, bad := range map[string]string{
|
|
"empty": "",
|
|
"no signature": "justanonce",
|
|
"bad sig": "nonce.not-a-real-signature",
|
|
"tampered": "x" + token,
|
|
} {
|
|
if c.ValidToken(bad) {
|
|
t.Errorf("%s token should not validate", name)
|
|
}
|
|
}
|
|
if NewCSRF([]byte("a-different-secret")).ValidToken(token) {
|
|
t.Error("a token must not validate under a different secret")
|
|
}
|
|
}
|
|
|
|
// TestCSRFMiddleware pins who is challenged and who is not: cookie-authenticated
|
|
// mutations need a valid token; bearer/API-key requests and safe methods do not.
|
|
func TestCSRFMiddleware(t *testing.T) {
|
|
c := NewCSRF([]byte("a-test-secret"))
|
|
token, _ := c.IssueToken()
|
|
reached := false
|
|
h := c.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
reached = true
|
|
}))
|
|
|
|
call := func(method string, setup func(*http.Request)) int {
|
|
reached = false
|
|
req := httptest.NewRequest(method, "/api/v1/rules", nil)
|
|
if setup != nil {
|
|
setup(req)
|
|
}
|
|
rec := httptest.NewRecorder()
|
|
h.ServeHTTP(rec, req)
|
|
return rec.Code
|
|
}
|
|
|
|
withCookie := func(r *http.Request) {
|
|
r.AddCookie(&http.Cookie{Name: DocsCookieName, Value: "sometoken"})
|
|
}
|
|
|
|
// Safe methods are never challenged.
|
|
if code := call(http.MethodGet, withCookie); code != http.StatusOK || !reached {
|
|
t.Errorf("GET with cookie: code=%d reached=%v, want pass-through", code, reached)
|
|
}
|
|
|
|
// Cookie-authenticated mutation without a token is rejected.
|
|
if code := call(http.MethodPost, withCookie); code != http.StatusForbidden || reached {
|
|
t.Errorf("POST with cookie and no CSRF token: code=%d reached=%v, want 403", code, reached)
|
|
}
|
|
|
|
// ... and accepted with a valid one.
|
|
if code := call(http.MethodPost, func(r *http.Request) {
|
|
withCookie(r)
|
|
r.Header.Set("X-CSRF-Token", token)
|
|
}); code != http.StatusOK || !reached {
|
|
t.Errorf("POST with a valid CSRF token: code=%d reached=%v, want pass-through", code, reached)
|
|
}
|
|
|
|
// An invalid token is rejected even though the header is present — this is
|
|
// exactly what the previous placeholder implementation let through.
|
|
if code := call(http.MethodPost, func(r *http.Request) {
|
|
withCookie(r)
|
|
r.Header.Set("X-CSRF-Token", "anything-at-all")
|
|
}); code != http.StatusForbidden || reached {
|
|
t.Errorf("POST with a bogus CSRF token: code=%d reached=%v, want 403", code, reached)
|
|
}
|
|
|
|
// Explicit credentials are not CSRF-reachable, so they pass untouched.
|
|
if code := call(http.MethodPost, func(r *http.Request) {
|
|
r.Header.Set("Authorization", "Bearer sometoken")
|
|
}); code != http.StatusOK || !reached {
|
|
t.Errorf("bearer POST: code=%d reached=%v, want pass-through", code, reached)
|
|
}
|
|
if code := call(http.MethodPost, func(r *http.Request) {
|
|
r.Header.Set("X-API-Key", "somekey")
|
|
}); code != http.StatusOK || !reached {
|
|
t.Errorf("api-key POST: code=%d reached=%v, want pass-through", code, reached)
|
|
}
|
|
|
|
// No ambient credential at all: let it through so auth returns 401.
|
|
if code := call(http.MethodPost, nil); code != http.StatusOK || !reached {
|
|
t.Errorf("anonymous POST: code=%d reached=%v, want pass-through to auth", code, reached)
|
|
}
|
|
}
|