Remediate six confirmed security issues: deployment-only config keys, bounded provider responses, short-lived registration sessions, private init file mode, FedWiki workflow authorization, and switch preview gates. - Add DeploymentOnly config key declaration; refuse runtime overrides for keys that decide where secrets are sent - Create httplimit package; bound all provider response reads at 8 MiB - Set fifteen-minute deadline on /register sessions - Write mc-config.yaml with 0600 permissions - Derive FedWiki workflow IDs from site IDs; re-authorize sites before mutating activities - Apply switch authorization gates to the proration preview
109 lines
3.6 KiB
Go
109 lines
3.6 KiB
Go
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Commercial
|
|
// SPDX-FileCopyrightText: 2025-2026 Christian Galo
|
|
|
|
package workflows
|
|
|
|
import (
|
|
"context"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.coopcloud.tech/wiki-cafe/member-console/internal/httplimit"
|
|
)
|
|
|
|
func newTokenServer(t *testing.T, calls *int) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
*calls++
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_, _ = w.Write([]byte(`{"access_token":"tok-` + string(rune('0'+*calls)) + `","expires_in":300,"token_type":"Bearer"}`))
|
|
}))
|
|
}
|
|
|
|
// The cached expiry must carry no monotonic clock reading, so that the
|
|
// validity check runs on the wall clock. Go's monotonic clock stops while
|
|
// the host sleeps; a monotonic comparison would keep a token past its exp
|
|
// after every resume.
|
|
func TestOAuthTokenProviderExpiryIsWallClock(t *testing.T) {
|
|
calls := 0
|
|
srv := newTokenServer(t, &calls)
|
|
defer srv.Close()
|
|
|
|
p, err := NewOAuthTokenProvider(OAuthTokenProviderConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := p.GetHeaders(context.Background()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Round(0) strips a monotonic reading; a time that already lacks one is
|
|
// unchanged, so equality here means the stored expiry has none.
|
|
if p.expiresAt != p.expiresAt.Round(0) {
|
|
t.Fatalf("expiresAt carries a monotonic reading: %v", p.expiresAt)
|
|
}
|
|
if until := time.Until(p.expiresAt); until < 4*time.Minute || until > 5*time.Minute {
|
|
t.Fatalf("expiresAt not about five minutes out: %v", until)
|
|
}
|
|
}
|
|
|
|
// A token inside the safety buffer is refetched; one outside it is reused.
|
|
func TestOAuthTokenProviderRefreshesInsideSafetyBuffer(t *testing.T) {
|
|
calls := 0
|
|
srv := newTokenServer(t, &calls)
|
|
defer srv.Close()
|
|
|
|
p, err := NewOAuthTokenProvider(OAuthTokenProviderConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, err := p.GetHeaders(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
again, err := p.GetHeaders(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != 1 || first[authHeaderKey] != again[authHeaderKey] {
|
|
t.Fatalf("fresh token was not reused: calls=%d", calls)
|
|
}
|
|
|
|
// Simulate the wall clock reaching the buffer (as it does after a
|
|
// suspend): the stored expiry is a wall-clock instant, so moving it is
|
|
// what the passage of wall time looks like to the check.
|
|
p.expiresAt = time.Now().Round(0).Add(p.safetyBuffer - time.Second)
|
|
third, err := p.GetHeaders(context.Background())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if calls != 2 || third[authHeaderKey] == first[authHeaderKey] {
|
|
t.Fatalf("token inside the safety buffer was not refreshed: calls=%d", calls)
|
|
}
|
|
}
|
|
|
|
// A token endpoint that streams past the shared bound is refused with the
|
|
// bound's own error (security-audit-remediation-3 design D5).
|
|
func TestOAuthTokenProviderOverBoundResponseIsRefused(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
chunk := []byte(strings.Repeat(" ", 64*1024))
|
|
for written := int64(0); written <= httplimit.DefaultResponseBytes; written += int64(len(chunk)) {
|
|
if _, err := w.Write(chunk); err != nil {
|
|
return
|
|
}
|
|
}
|
|
}))
|
|
defer srv.Close()
|
|
p, err := NewOAuthTokenProvider(OAuthTokenProviderConfig{TokenURL: srv.URL, ClientID: "id", ClientSecret: "secret"})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
_, err = p.GetHeaders(context.Background())
|
|
if !httplimit.IsTooLarge(err) {
|
|
t.Fatalf("want the bounded reader's refusal, got %v", err)
|
|
}
|
|
}
|