Secure configuration transfer authorization (#1714)

Co-authored-by: Pulse Autonomous Maintainer <rcourtman@users.noreply.github.com>
This commit is contained in:
rcourtman
2026-08-12 07:32:50 +01:00
committed by GitHub
parent 359a372cdf
commit 3adeb77d60
18 changed files with 908 additions and 281 deletions
+11 -2
View File
@@ -236,6 +236,12 @@ Environment="ALLOW_UNPROTECTED_EXPORT=true"
docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest
```
This exception applies only when Pulse has no configured authentication and
only to configuration export. It never enables import and never overrides
password, proxy, API-token, SSO, or hosted authentication. Without the
exception, unauthenticated export and import recovery is limited to a direct
loopback connection; private-network and forwarded requests are not loopback.
**Note:** for production, prefer Docker secrets or systemd environment files
for sensitive data.
@@ -419,7 +425,10 @@ curl -X POST \
http://localhost:7655/api/config/export
```
Most API endpoints also accept `Authorization: Bearer <token>`, but export/import uses the `X-API-Token` header.
Configuration export accepts a token with `settings:read`; import accepts a
token with `settings:write`. Both `X-API-Token` and `Authorization: Bearer`
forms are supported, and organization-bound tokens can transfer only the
organization selected by the request.
### Scoped API Tokens
@@ -633,7 +642,7 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \
## Troubleshooting
**Account locked?** Wait 15 minutes or contact admin for manual reset
**Export blocked?** You're on a public network login with password, create an API token, or set `ALLOW_UNPROTECTED_EXPORT=true`
**Export blocked?** Authenticate with management authority, use a correctly scoped API token, connect directly over loopback on a no-auth installation, or deliberately set `ALLOW_UNPROTECTED_EXPORT=true` for export only<br>
**Rate limited?** Wait 1 minute and try again
**Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables
**API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash)
+6 -2
View File
@@ -360,7 +360,9 @@ Validates node config without saving.
`POST /api/config/nodes/{id}/refresh-cluster`
### Export Configuration
`POST /api/config/export` (admin or API token)
`POST /api/config/export` (instance admin for the default organization, tenant
manager for a selected tenant, or an API token bound to the selected
organization with `settings:read`)
Request body:
```json
{ "passphrase": "use-a-strong-passphrase" }
@@ -368,7 +370,9 @@ Request body:
Returns an encrypted export bundle in `data`. Passphrases must be at least 12 characters.
### Import Configuration
`POST /api/config/import` (admin)
`POST /api/config/import` (instance admin for the default organization, tenant
manager for a selected tenant, or an API token bound to the selected
organization with `settings:write`)
Request body:
```json
{
@@ -6197,3 +6197,15 @@ operator or external agent can explain current attention behavior, without
changing registration or command authority. Retiring an agent-backed resource
blocks automated remediation through the shared action policy while preserving
its enrollment and history.
### Configuration transfer authority does not derive from agent membership
The shared `internal/api/` configuration export/import routes use browser,
proxy, Basic, or API-token administration authority for the resolved
organization before entering persistence. Agent registration, report, setup,
and command credentials do not become configuration-transfer authority merely
because they authenticate another lifecycle route. An API token must retain
organization binding and carry `settings:read` for export or `settings:write`
for import; tenant browser membership without management authority is denied.
This boundary neither enrolls nor mutates agent identity, tokens, command
sessions, fleet policy, or update state.
@@ -9306,3 +9306,28 @@ the canonical capability manifest project both new fields. Retired lifecycle
is also an execution-time remediation lock in the shared action lifecycle.
`internal/api/resources_operator_state_test.go`, agent-context tests, manifest
tests, and the generated `cmd/pulse-mcp/README.md` pin this additive contract.
### Configuration transfer has one organization-aware authorization boundary
`POST /api/config/export` and `POST /api/config/import` remain route-local
public exceptions only so deliberate no-auth recovery can be evaluated. Before
either handler reads a body or resolves persistence, the shared transfer guard
classifies password, proxy, API-token, persisted or environment-backed SSO,
hosted, and SSO-load-failure state. Any configured or uncertain authentication
state requires a real credential. Sessions require instance-admin authority on
the default organization or `CanUserIDManage` on the resolved tenant; tenant
membership alone is insufficient. API tokens retain tenant-middleware
organization binding and require `settings:read` for export or `settings:write`
for import, including legacy unbound-token compatibility on the default
organization only.
When no authentication exists, import and export recovery are limited to a
direct loopback peer with no forwarding headers. `ALLOW_UNPROTECTED_EXPORT=true`
may additionally admit export only; it cannot admit import or override any
authenticated or hosted mode. Denials precede archive decoding, export reads,
import transactions, live-config replacement, and runtime reload. The public
and privileged security-status projections use the same classification, and
the canonical and shipped API references describe the same scope and tenant
rules. `config_transfer_authorization_test.go`, the contract ratchet in
`contract_test.go`, and the shipped-doc assertions in `docsLinks.test.ts` pin
the boundary and its published shape.
@@ -2387,3 +2387,13 @@ operator-state read for that selected resource and does not add table-row,
hover-preview, interval, or websocket work. Saving monitoring or lifecycle
policy performs one mutation and one selected-resource refetch. The Workloads
list, selectors, windowing, and polling budgets remain unchanged.
### Configuration transfer authorization stays off persistence hot paths
Persisted and environment-backed SSO state is loaded into the router's auth
snapshot during construction. Export/import authorization then uses in-memory
configuration, session, token-context, organization-context, and hosted-state
checks before handing the request to either body-decoding handler. Denied
requests perform no configuration export loads, import transaction work,
metadata replacement, or runtime reload. Authorized archive encryption,
decryption, transactional persistence, and reload costs are unchanged.
@@ -2163,3 +2163,23 @@ the tenant resource store. Runtime reconciliation may visit every live monitor,
but each alert manager resolves policy through its own tenant-scoped store, so a
matching provider ID in another organization cannot import the mutation. The
existing route scopes and authenticated actor attribution remain unchanged.
### Secret-bearing configuration transfer fails closed before data access
Configuration archives may contain node credentials, notification secrets,
API-token hashes and metadata, OIDC client secrets, and SAML private keys. The
export/import router therefore authorizes the resolved organization before
request-body parsing or persistence access. Hosted mode, enabled persisted or
environment-backed SSO, and an SSO load failure all require authentication;
none may fall through to no-auth recovery. Browser sessions need instance-admin
or tenant-management authority, proxy identities need the configured admin
role, and API tokens preserve organization binding plus the operation-specific
`settings:read` or `settings:write` scope.
No-auth recovery trusts only a direct loopback transport without forwarded
identity headers. `ALLOW_UNPROTECTED_EXPORT` is an export-only exception and is
ineffective once any authentication or hosted mode is active. Security status
reports that effective policy rather than the environment variable alone, and
the root and shipped security guides remain byte-for-byte synchronized. The
router matrix proves that malformed and otherwise valid denied requests read no
body and perform no export, import, config replacement, or runtime reload.
@@ -5188,3 +5188,21 @@ configuration, or provider inventory. A retired resource fails closed for
automated remediation through the shared action lifecycle, while recovery
evidence remains available for operator review and for restoration to active
monitoring.
### Configuration archive recovery is authorized before persistence
Encrypted configuration export/import is a storage-recovery boundary only
after authorization succeeds. The API guard resolves the selected organization
and authenticating principal before either handler decodes an archive, reads
the tenant export persistence, begins an import transaction, replaces live
configuration, or reloads monitoring state. A tenant viewer or member cannot
transfer configuration; tenant managers and owners may operate only on their
resolved tenant, and scoped API tokens retain their organization binding.
Truly unauthenticated recovery is limited to direct loopback for both
operations. The deliberate `ALLOW_UNPROTECTED_EXPORT` exception widens export
only and cannot authorize import. Existing encryption, archive versions,
transactional rollback, metadata replacement, and successful reload semantics
remain unchanged behind this boundary. The config-transfer router matrix and
existing archive compatibility/rollback tests jointly pin denial-before-access
and authorized recovery behavior.
+6 -2
View File
@@ -360,7 +360,9 @@ Validates node config without saving.
`POST /api/config/nodes/{id}/refresh-cluster`
### Export Configuration
`POST /api/config/export` (admin or API token)
`POST /api/config/export` (instance admin for the default organization, tenant
manager for a selected tenant, or an API token bound to the selected
organization with `settings:read`)
Request body:
```json
{ "passphrase": "use-a-strong-passphrase" }
@@ -368,7 +370,9 @@ Request body:
Returns an encrypted export bundle in `data`. Passphrases must be at least 12 characters.
### Import Configuration
`POST /api/config/import` (admin)
`POST /api/config/import` (instance admin for the default organization, tenant
manager for a selected tenant, or an API token bound to the selected
organization with `settings:write`)
Request body:
```json
{
+11 -2
View File
@@ -236,6 +236,12 @@ Environment="ALLOW_UNPROTECTED_EXPORT=true"
docker run -e ALLOW_UNPROTECTED_EXPORT=true rcourtman/pulse:latest
```
This exception applies only when Pulse has no configured authentication and
only to configuration export. It never enables import and never overrides
password, proxy, API-token, SSO, or hosted authentication. Without the
exception, unauthenticated export and import recovery is limited to a direct
loopback connection; private-network and forwarded requests are not loopback.
**Note:** for production, prefer Docker secrets or systemd environment files
for sensitive data.
@@ -419,7 +425,10 @@ curl -X POST \
http://localhost:7655/api/config/export
```
Most API endpoints also accept `Authorization: Bearer <token>`, but export/import uses the `X-API-Token` header.
Configuration export accepts a token with `settings:read`; import accepts a
token with `settings:write`. Both `X-API-Token` and `Authorization: Bearer`
forms are supported, and organization-bound tokens can transfer only the
organization selected by the request.
### Scoped API Tokens
@@ -633,7 +642,7 @@ curl -X POST http://localhost:7655/api/security/reset-lockout \
## Troubleshooting
**Account locked?** Wait 15 minutes or contact admin for manual reset
**Export blocked?** You're on a public network login with password, create an API token, or set `ALLOW_UNPROTECTED_EXPORT=true`
**Export blocked?** Authenticate with management authority, use a correctly scoped API token, connect directly over loopback on a no-auth installation, or deliberately set `ALLOW_UNPROTECTED_EXPORT=true` for export only<br>
**Rate limited?** Wait 1 minute and try again
**Can't login?** Check `PULSE_AUTH_USER` and `PULSE_AUTH_PASS` environment variables
**API access denied?** Verify the token you supplied matches one of the values created in *Settings → API Tokens* (use the original token, not the hash)
@@ -130,6 +130,28 @@ describe('docsLinks', () => {
expect(rbacGuide).toMatch(/Removal does not disable\s+the upstream IdP account/);
});
it('ships the configuration transfer authorization contract', () => {
const apiReference = readFileSync(path.join(repoRoot, 'docs', 'API.md'), 'utf8');
const shippedAPIReference = readFileSync(
path.join(frontendRoot, 'public', 'docs', 'API.md'),
'utf8',
);
expect(shippedAPIReference).toBe(apiReference);
const exportContract = apiReference
.split('### Export Configuration')[1]
?.split('### Import Configuration')[0]
?.replace(/\s+/g, ' ');
const importContract = apiReference
.split('### Import Configuration')[1]
?.split('---')[0]
?.replace(/\s+/g, ' ');
expect(exportContract).toContain('tenant manager');
expect(exportContract).toContain('settings:read');
expect(importContract).toContain('tenant manager');
expect(importContract).toContain('settings:write');
});
it('routes runtime docs links through shipped local docs instead of GitHub main', () => {
expect(apiAccessPanelSource).toContain('API_TOKEN_SCOPES_DOC_URL');
expect(apiAccessPanelSource).not.toContain(
@@ -0,0 +1,152 @@
package api
import (
"net/http"
"os"
"strings"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
"github.com/rs/zerolog/log"
)
type configTransferOperation string
const (
configTransferExport configTransferOperation = "export"
configTransferImport configTransferOperation = "import"
)
func (op configTransferOperation) requiredScope() string {
if op == configTransferImport {
return config.ScopeSettingsWrite
}
return config.ScopeSettingsRead
}
// configTransferAuthenticationConfigured is the canonical fail-closed view of
// whether configuration transfer must authenticate. It deliberately includes
// hosted operation and an uncertain SSO load: neither state may fall back to
// unauthenticated recovery.
func (r *Router) configTransferAuthenticationConfigured() bool {
if r == nil || r.config == nil {
return true
}
config.Mu.RLock()
localAuthConfigured := strings.TrimSpace(r.config.AuthUser) != "" || strings.TrimSpace(r.config.AuthPass) != ""
tokenAuthConfigured := r.config.HasAPITokens()
proxyAuthConfigured := strings.TrimSpace(r.config.ProxyAuthSecret) != ""
config.Mu.RUnlock()
return localAuthConfigured ||
tokenAuthConfigured ||
proxyAuthConfigured ||
hasEnabledSSOProvidersForAuth(r.config) ||
r.hostedMode ||
r.ssoAuthenticationLoadFailed()
}
func (r *Router) allowUnauthenticatedConfigTransfer(req *http.Request, op configTransferOperation) bool {
if r.configTransferAuthenticationConfigured() {
return false
}
if isDirectLoopbackRequest(req) {
return true
}
return op == configTransferExport && os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true"
}
// authorizeConfigTransfer is the single route-local boundary for export and
// import. It runs before either handler receives the request, so denial cannot
// parse an archive, read export persistence, write import state, or reload the
// runtime.
func (r *Router) authorizeConfigTransfer(w http.ResponseWriter, req *http.Request, op configTransferOperation) bool {
if adminBypassEnabled() {
return true
}
if r == nil || r.config == nil {
http.Error(w, "Configuration authorization unavailable", http.StatusServiceUnavailable)
return false
}
scope := op.requiredScope()
// Explicit API-token credentials take precedence over every browser
// credential. AuthContextMiddleware has already validated the token against
// the resolved tenant config, and TenantMiddleware has enforced its org
// binding before this route can run.
if _, provided := explicitAPITokenFromRequest(req); provided {
record := getAPITokenRecordFromRequest(req)
if record == nil {
http.Error(w, "Invalid API token", http.StatusUnauthorized)
return false
}
if !record.HasScope(scope) {
respondMissingScope(w, scope)
return false
}
return true
}
// A valid proxy identity is authoritative. Membership-like proxy roles are
// insufficient for a secret-bearing transfer.
if strings.TrimSpace(r.config.ProxyAuthSecret) != "" {
if valid, username, isAdmin := CheckProxyAuth(r.config, req); valid {
if !isAdmin {
logAuthDenial(req, username, "Non-admin proxy user attempted configuration transfer", nil)
http.Error(w, "Admin privileges required for configuration transfer", http.StatusForbidden)
return false
}
return true
}
}
// A presented valid session must itself carry management authority. For a
// tenant this is CanUserIDManage on the resolved organization; for the
// default organization it is the canonical instance-admin rule.
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" {
session := GetSessionStore().GetSession(cookie.Value)
validSession := session != nil && ValidateSession(cookie.Value)
if validSession && session.RecoveryBypass {
validSession = requestMatchesRecoverySession(req, session)
}
if validSession {
if !ensureAdminSession(r.config, w, req) {
return false
}
return true
}
}
// HTTP Basic represents the configured instance administrator. Validate it
// directly so this decision cannot accidentally inherit no-auth fallback.
if username, password, ok := req.BasicAuth(); ok {
config.Mu.RLock()
configuredUser := r.config.AuthUser
configuredHash := r.config.AuthPass
config.Mu.RUnlock()
if configuredUser != "" && configuredHash != "" &&
constantTimeStringEqual(username, configuredUser) &&
internalauth.CheckPasswordHash(password, configuredHash) {
return true
}
}
if r.allowUnauthenticatedConfigTransfer(req, op) {
return true
}
if r.configTransferAuthenticationConfigured() {
logAuthDenial(req, "", "Unauthenticated configuration transfer attempt", nil)
http.Error(w, "Unauthorized - please log in or provide an API token", http.StatusUnauthorized)
return false
}
log.Warn().
Str("ip", req.RemoteAddr).
Str("operation", string(op)).
Msg("Configuration transfer blocked outside direct loopback recovery policy")
http.Error(w, "Configuration transfer requires authentication outside direct loopback", http.StatusForbidden)
return false
}
@@ -0,0 +1,512 @@
package api
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/models"
"github.com/rcourtman/pulse-go-rewrite/internal/monitoring"
internalauth "github.com/rcourtman/pulse-go-rewrite/pkg/auth"
)
type countingConfigTransferBody struct {
reader io.Reader
reads int
}
func (b *countingConfigTransferBody) Read(p []byte) (int, error) {
b.reads++
return b.reader.Read(p)
}
func newConfigTransferTestRouter(t *testing.T, hosted bool, sso *config.SSOConfig) *Router {
t.Helper()
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
if hosted {
t.Setenv("PULSE_HOSTED_MODE", "true")
} else {
t.Setenv("PULSE_HOSTED_MODE", "false")
}
cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir}
if sso != nil {
if err := config.NewConfigPersistence(dataDir).SaveSSOConfig(sso); err != nil {
t.Fatalf("save synthetic SSO config: %v", err)
}
}
return NewRouter(cfg, nil, nil, nil, nil, "test")
}
func enabledConfigTransferSSO(providerType config.SSOProviderType) *config.SSOConfig {
provider := config.SSOProvider{ID: "synthetic", Name: "Synthetic", Type: providerType, Enabled: true}
if providerType == config.SSOProviderTypeOIDC {
provider.OIDC = &config.OIDCProviderConfig{IssuerURL: "https://idp.invalid", ClientID: "synthetic"}
} else {
provider.SAML = &config.SAMLProviderConfig{IDPEntityID: "https://idp.invalid"}
}
return &config.SSOConfig{Providers: []config.SSOProvider{provider}}
}
func configTransferRequest(t *testing.T, router *Router, path, remoteAddr, body string) (*httptest.ResponseRecorder, *countingConfigTransferBody) {
t.Helper()
counted := &countingConfigTransferBody{reader: strings.NewReader(body)}
req := httptest.NewRequest(http.MethodPost, path, counted)
req.RemoteAddr = remoteAddr
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
return rec, counted
}
func TestConfigTransferAnonymousAuthenticatedModesDenyBeforeBodyRead(t *testing.T) {
tests := []struct {
name string
hosted bool
sso *config.SSOConfig
}{
{name: "persisted OIDC", sso: enabledConfigTransferSSO(config.SSOProviderTypeOIDC)},
{name: "persisted SAML", sso: enabledConfigTransferSSO(config.SSOProviderTypeSAML)},
{name: "hosted", hosted: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
router := newConfigTransferTestRouter(t, tc.hosted, tc.sso)
requests := []struct {
path string
body string
}{
{path: "/api/config/export", body: `{not-json`},
{path: "/api/config/export", body: `{"passphrase":"synthetic-passphrase"}`},
{path: "/api/config/import", body: `{not-json`},
{path: "/api/config/import", body: `{"passphrase":"synthetic-passphrase","data":"synthetic-archive"}`},
}
for i, request := range requests {
remoteAddr := "127.0.0." + string(rune('1'+i)) + ":1234"
rec, body := configTransferRequest(t, router, request.path, remoteAddr, request.body)
if rec.Code != http.StatusUnauthorized {
t.Errorf("%s status = %d, want 401: %s", request.path, rec.Code, rec.Body.String())
}
if body.reads != 0 {
t.Errorf("%s read denied request body %d times", request.path, body.reads)
}
}
})
}
}
func TestConfigTransferEnvironmentOIDCAndSSOLoadFailureFailClosed(t *testing.T) {
t.Run("environment OIDC", func(t *testing.T) {
t.Setenv("OIDC_ENABLED", "true")
t.Setenv("OIDC_ISSUER_URL", "https://idp.invalid")
t.Setenv("OIDC_CLIENT_ID", "synthetic")
router := newConfigTransferTestRouter(t, false, nil)
for _, path := range []string{"/api/config/export", "/api/config/import"} {
rec, body := configTransferRequest(t, router, path, "127.0.0.1:1234", `{not-json`)
if rec.Code != http.StatusUnauthorized || body.reads != 0 {
t.Errorf("%s environment OIDC denial = status %d, reads %d", path, rec.Code, body.reads)
}
}
})
t.Run("unreadable persisted SSO", func(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
t.Setenv("PULSE_HOSTED_MODE", "false")
// Establish the synthetic encryption key before introducing a corrupt
// encrypted payload; production persistence is never consulted.
config.NewConfigPersistence(dataDir)
if err := os.WriteFile(filepath.Join(dataDir, "sso.enc"), []byte("synthetic-corruption"), 0o600); err != nil {
t.Fatalf("write corrupt synthetic SSO: %v", err)
}
cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir}
router := NewRouter(cfg, nil, nil, nil, nil, "test")
if !router.ssoAuthenticationLoadFailed() {
t.Fatal("precondition: corrupt persisted SSO was not recorded as a load failure")
}
rec, body := configTransferRequest(t, router, "/api/config/export", "127.0.0.1:1234", `{not-json`)
if rec.Code != http.StatusUnauthorized || body.reads != 0 {
t.Fatalf("SSO load failure denial = status %d, reads %d", rec.Code, body.reads)
}
})
}
func TestConfigTransferNoAuthUsesDirectLoopbackPolicy(t *testing.T) {
t.Setenv("ALLOW_UNPROTECTED_EXPORT", "false")
router := newConfigTransferTestRouter(t, false, nil)
for _, path := range []string{"/api/config/export", "/api/config/import"} {
rec, body := configTransferRequest(t, router, path, "192.168.1.50:1234", `{not-json`)
if rec.Code != http.StatusForbidden {
t.Errorf("%s private-peer status = %d, want 403: %s", path, rec.Code, rec.Body.String())
}
if body.reads != 0 {
t.Errorf("%s read private-peer denied body %d times", path, body.reads)
}
}
for _, path := range []string{"/api/config/export", "/api/config/import"} {
rec, body := configTransferRequest(t, router, path, "127.0.0.1:1234", `{not-json`)
if rec.Code != http.StatusBadRequest {
t.Errorf("%s loopback status = %d, want handler 400: %s", path, rec.Code, rec.Body.String())
}
if body.reads == 0 {
t.Errorf("%s loopback request did not reach handler", path)
}
}
}
func TestAllowUnprotectedExportIsExportOnly(t *testing.T) {
t.Setenv("ALLOW_UNPROTECTED_EXPORT", "true")
router := newConfigTransferTestRouter(t, false, nil)
exportRec, exportBody := configTransferRequest(t, router, "/api/config/export", "203.0.113.10:1234", `{not-json`)
if exportRec.Code != http.StatusBadRequest || exportBody.reads == 0 {
t.Fatalf("unprotected export did not reach handler: status=%d reads=%d body=%s", exportRec.Code, exportBody.reads, exportRec.Body.String())
}
importRec, importBody := configTransferRequest(t, router, "/api/config/import", "203.0.113.10:1234", `{not-json`)
if importRec.Code != http.StatusForbidden {
t.Fatalf("import status = %d, want 403: %s", importRec.Code, importRec.Body.String())
}
if importBody.reads != 0 {
t.Fatalf("import override denial read body %d times", importBody.reads)
}
}
func TestAllowUnprotectedExportCannotOverrideAuthentication(t *testing.T) {
t.Setenv("ALLOW_UNPROTECTED_EXPORT", "true")
router := newConfigTransferTestRouter(t, false, enabledConfigTransferSSO(config.SSOProviderTypeOIDC))
for _, path := range []string{"/api/config/export", "/api/config/import"} {
rec, body := configTransferRequest(t, router, path, "203.0.113.12:1234", `{not-json`)
if rec.Code != http.StatusUnauthorized || body.reads != 0 {
t.Errorf("%s authenticated override denial = status %d, reads %d", path, rec.Code, body.reads)
}
}
}
func TestConfigTransferNoAuthRejectsForwardedLoopback(t *testing.T) {
t.Setenv("ALLOW_UNPROTECTED_EXPORT", "false")
router := newConfigTransferTestRouter(t, false, nil)
for _, header := range []string{"X-Forwarded-For", "Forwarded", "X-Real-IP"} {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, "/api/config/import", body)
req.RemoteAddr = "127.0.0.1:1234"
req.Header.Set(header, "127.0.0.1")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || body.reads != 0 {
t.Errorf("%s forwarded loopback = status %d, reads %d", header, rec.Code, body.reads)
}
}
}
func TestConfigTransferAuthorizedInstanceModesReachHandler(t *testing.T) {
hash, err := internalauth.HashPassword("SyntheticPassword!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
t.Run("basic admin", func(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
cfg := &config.Config{DataPath: dataDir, ConfigPath: dataDir, AuthUser: "admin", AuthPass: hash}
router := NewRouter(cfg, nil, nil, nil, nil, "test")
for _, path := range []string{"/api/config/export", "/api/config/import"} {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, path, body)
req.SetBasicAuth("admin", "SyntheticPassword!1")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || body.reads == 0 {
t.Errorf("%s basic admin = status %d, reads %d", path, rec.Code, body.reads)
}
}
})
for _, providerType := range []config.SSOProviderType{config.SSOProviderTypeOIDC, config.SSOProviderTypeSAML} {
t.Run("SSO session "+string(providerType), func(t *testing.T) {
router := newConfigTransferTestRouter(t, false, enabledConfigTransferSSO(providerType))
sessionToken := "synthetic-sso-session-" + string(providerType)
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "sso:owner@example.invalid")
csrf := generateCSRFToken(sessionToken)
for _, path := range []string{"/api/config/export", "/api/config/import"} {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, path, body)
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
req.Header.Set("X-CSRF-Token", csrf)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || body.reads == 0 {
t.Errorf("%s SSO admin = status %d, reads %d", path, rec.Code, body.reads)
}
}
})
}
t.Run("proxy admin", func(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
cfg := &config.Config{
DataPath: dataDir, ConfigPath: dataDir,
ProxyAuthSecret: "synthetic-proxy-secret", ProxyAuthUserHeader: "X-Proxy-User",
ProxyAuthRoleHeader: "X-Proxy-Roles", ProxyAuthAdminRole: "admin",
}
router := NewRouter(cfg, nil, nil, nil, nil, "test")
for _, path := range []string{"/api/config/export", "/api/config/import"} {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, path, body)
req.Header.Set("X-Proxy-Secret", cfg.ProxyAuthSecret)
req.Header.Set("X-Proxy-User", "proxy-admin")
req.Header.Set("X-Proxy-Roles", "viewer|admin")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || body.reads == 0 {
t.Errorf("%s proxy admin = status %d, reads %d", path, rec.Code, body.reads)
}
}
})
}
func TestConfigTransferTokenScopesAndOrganizationBinding(t *testing.T) {
readRaw := "synthetic-read-token-123.12345678"
writeRaw := "synthetic-write-token-123.12345678"
readRecord := newTokenRecord(t, readRaw, []string{config.ScopeSettingsRead}, nil)
writeRecord := newTokenRecord(t, writeRaw, []string{config.ScopeSettingsWrite}, nil)
cfg := newTestConfigWithTokens(t, readRecord, writeRecord)
t.Setenv("PULSE_DATA_DIR", cfg.DataPath)
router := NewRouter(cfg, nil, nil, nil, nil, "test")
tests := []struct {
name string
path string
token string
bearer bool
status int
reads bool
}{
{name: "read scope exports", path: "/api/config/export", token: readRaw, status: http.StatusBadRequest, reads: true},
{name: "read scope cannot import", path: "/api/config/import", token: readRaw, status: http.StatusForbidden},
{name: "write scope imports with bearer", path: "/api/config/import", token: writeRaw, bearer: true, status: http.StatusBadRequest, reads: true},
{name: "write scope cannot export", path: "/api/config/export", token: writeRaw, status: http.StatusForbidden},
{name: "invalid token", path: "/api/config/export", token: "invalid-synthetic-token", status: http.StatusUnauthorized},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, tc.path, body)
req.RemoteAddr = "127.0.1." + string(rune('1'+i)) + ":1234"
if tc.bearer {
req.Header.Set("Authorization", "Bearer "+tc.token)
} else {
req.Header.Set("X-API-Token", tc.token)
}
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("status = %d, want %d: %s", rec.Code, tc.status, rec.Body.String())
}
if (body.reads > 0) != tc.reads {
t.Fatalf("body reads = %d, want reached=%v", body.reads, tc.reads)
}
})
}
boundRaw := "synthetic-bound-token-123.12345678"
boundRecord := newTokenRecord(t, boundRaw, []string{config.ScopeSettingsRead}, nil)
boundRecord.OrgID = "tenant-a"
boundCfg := newTestConfigWithTokens(t, boundRecord)
t.Setenv("PULSE_DATA_DIR", boundCfg.DataPath)
boundRouter := NewRouter(boundCfg, nil, nil, nil, nil, "test")
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, "/api/config/export", body)
req.Header.Set("X-API-Token", boundRaw)
req.Header.Set("X-Pulse-Org-ID", "default")
rec := httptest.NewRecorder()
boundRouter.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || body.reads != 0 {
t.Fatalf("cross-organization token denial = status %d, reads %d", rec.Code, body.reads)
}
}
func TestConfigTransferTenantSessionsRequireManagement(t *testing.T) {
defer SetMultiTenantEnabled(false)
SetMultiTenantEnabled(true)
t.Setenv("PULSE_DEV", "true")
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
hash, err := internalauth.HashPassword("SyntheticPassword!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
tenantTokenRaw := "synthetic-tenant-token-123.12345678"
tenantToken := newTokenRecord(t, tenantTokenRaw, []string{config.ScopeSettingsRead}, nil)
tenantToken.OrgID = "tenant-a"
cfg := &config.Config{
DataPath: dataDir, ConfigPath: dataDir, AuthUser: "instance-admin", AuthPass: hash,
APITokens: []config.APITokenRecord{tenantToken},
}
org := &models.Organization{
ID: "tenant-a", DisplayName: "Tenant A", OwnerUserID: "owner",
Members: []models.OrganizationMember{
{UserID: "owner", Role: models.OrgRoleOwner, AddedAt: time.Now()},
{UserID: "manager", Role: models.OrgRoleAdmin, AddedAt: time.Now()},
{UserID: "viewer", Role: models.OrgRoleViewer, AddedAt: time.Now()},
},
}
mtp := config.NewMultiTenantPersistence(dataDir)
if err := mtp.SaveOrganization(org); err != nil {
t.Fatalf("save synthetic organization: %v", err)
}
if err := mtp.SaveOrganization(&models.Organization{ID: "tenant-b", DisplayName: "Tenant B", OwnerUserID: "other-owner"}); err != nil {
t.Fatalf("save cross-organization fixture: %v", err)
}
mtm := monitoring.NewMultiTenantMonitor(cfg, mtp, nil)
t.Cleanup(mtm.Stop)
router := NewRouter(cfg, nil, mtm, nil, nil, "test")
tests := []struct {
user string
status int
reached bool
}{
{user: "viewer", status: http.StatusForbidden},
{user: "manager", status: http.StatusBadRequest, reached: true},
{user: "owner", status: http.StatusBadRequest, reached: true},
}
for i, tc := range tests {
t.Run(tc.user, func(t *testing.T) {
sessionToken := "tenant-transfer-session-" + tc.user
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", tc.user)
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, "/api/config/import", body)
req.RemoteAddr = "127.0.2." + string(rune('1'+i)) + ":1234"
req.Header.Set("X-Pulse-Org-ID", "tenant-a")
req.Header.Set("X-CSRF-Token", generateCSRFToken(sessionToken))
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != tc.status {
t.Fatalf("status = %d, want %d: %s", rec.Code, tc.status, rec.Body.String())
}
if (body.reads > 0) != tc.reached {
t.Fatalf("body reads = %d, want reached=%v", body.reads, tc.reached)
}
})
}
t.Run("organization-bound token selects and reaches its tenant", func(t *testing.T) {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, "/api/config/export", body)
req.Header.Set("X-API-Token", tenantTokenRaw)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest || body.reads == 0 {
t.Fatalf("bound tenant token = status %d, reads %d: %s", rec.Code, body.reads, rec.Body.String())
}
})
t.Run("organization-bound token cannot cross tenants", func(t *testing.T) {
body := &countingConfigTransferBody{reader: strings.NewReader(`{not-json`)}
req := httptest.NewRequest(http.MethodPost, "/api/config/export", body)
req.Header.Set("X-API-Token", tenantTokenRaw)
req.Header.Set("X-Pulse-Org-ID", "tenant-b")
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusForbidden || body.reads != 0 {
t.Fatalf("cross-tenant token = status %d, reads %d: %s", rec.Code, body.reads, rec.Body.String())
}
})
}
func TestSecurityStatusMatchesConfigTransferPolicy(t *testing.T) {
t.Run("hosted requires authentication", func(t *testing.T) {
router := newConfigTransferTestRouter(t, true, nil)
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
var status map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
t.Fatalf("decode hosted security status: %v", err)
}
if status["requiresAuth"] != true || status["hasAuthentication"] != true {
t.Fatalf("hosted security status did not report required auth: %#v", status)
}
})
t.Run("export override is ineffective with SSO", func(t *testing.T) {
t.Setenv("ALLOW_UNPROTECTED_EXPORT", "true")
router := newConfigTransferTestRouter(t, false, enabledConfigTransferSSO(config.SSOProviderTypeOIDC))
sessionToken := "synthetic-security-status-session"
GetSessionStore().CreateSession(sessionToken, time.Hour, "browser", "127.0.0.1", "sso:owner@example.invalid")
req := httptest.NewRequest(http.MethodGet, "/api/security/status", nil)
req.AddCookie(&http.Cookie{Name: cookieNameSession, Value: sessionToken})
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
var status map[string]any
if err := json.Unmarshal(rec.Body.Bytes(), &status); err != nil {
t.Fatalf("decode SSO security status: %v", err)
}
if status["detailLevel"] != securityStatusDetailPrivileged {
t.Fatalf("security status detail = %v, want privileged: %#v", status["detailLevel"], status)
}
if status["exportProtected"] != true || status["unprotectedExportAllowed"] != false {
t.Fatalf("security status misreported authenticated export override: %#v", status)
}
})
}
func TestDeniedConfigTransferDoesNotMutateOrReload(t *testing.T) {
dataDir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dataDir)
hash, err := internalauth.HashPassword("SyntheticPassword!1")
if err != nil {
t.Fatalf("hash password: %v", err)
}
cfg := &config.Config{
DataPath: dataDir, ConfigPath: dataDir,
AuthUser: "synthetic-admin", AuthPass: hash, PublicURL: "https://before.invalid",
}
reloadCalls := 0
router := NewRouter(cfg, nil, nil, nil, func() error {
reloadCalls++
return nil
}, "test")
requests := []struct {
path string
body string
}{
{path: "/api/config/export", body: `{"passphrase":"synthetic-passphrase"}`},
{path: "/api/config/import", body: `{"passphrase":"synthetic-passphrase","data":"synthetic-archive"}`},
{path: "/api/config/import", body: `{not-json`},
}
for i, request := range requests {
body := &countingConfigTransferBody{reader: strings.NewReader(request.body)}
req := httptest.NewRequest(http.MethodPost, request.path, body)
req.RemoteAddr = "203.0.113." + string(rune('1'+i)) + ":1234"
rec := httptest.NewRecorder()
router.Handler().ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Fatalf("%s denied status = %d: %s", request.path, rec.Code, rec.Body.String())
}
if body.reads != 0 {
t.Fatalf("%s denial read request body %d times", request.path, body.reads)
}
}
if reloadCalls != 0 {
t.Fatalf("denied import reloaded runtime %d times", reloadCalls)
}
if cfg.PublicURL != "https://before.invalid" || cfg.AuthUser != "synthetic-admin" || cfg.AuthPass != hash {
t.Fatalf("denied import mutated live config: %+v", cfg)
}
}
+48
View File
@@ -67,6 +67,54 @@ import (
tmock "github.com/stretchr/testify/mock"
)
func TestContract_ConfigTransferRoutesUseCanonicalPreHandlerAuthorization(t *testing.T) {
routesSource, err := os.ReadFile(filepath.Clean("router_routes_registration.go"))
if err != nil {
t.Fatalf("read config route registration: %v", err)
}
routes := string(routesSource)
if got := strings.Count(routes, "authorizeConfigTransfer(w, req,"); got != 2 {
t.Fatalf("config transfer authorization calls = %d, want exactly export and import", got)
}
if strings.Contains(routes, "ALLOW_UNPROTECTED_EXPORT") || strings.Contains(routes, "isPrivateIP(") {
t.Fatal("config transfer routes reintroduced a local override or private-network approximation")
}
for _, sequence := range []struct {
authorize string
handler string
}{
{authorize: "authorizeConfigTransfer(w, req, configTransferExport)", handler: "HandleExportConfig(w, req)"},
{authorize: "authorizeConfigTransfer(w, req, configTransferImport)", handler: "HandleImportConfig(w, req)"},
} {
authorizeAt := strings.Index(routes, sequence.authorize)
handlerAt := strings.Index(routes, sequence.handler)
if authorizeAt < 0 || handlerAt < 0 || authorizeAt >= handlerAt {
t.Fatalf("%s must precede %s", sequence.authorize, sequence.handler)
}
}
authoritySource, err := os.ReadFile(filepath.Clean("config_transfer_authorization.go"))
if err != nil {
t.Fatalf("read canonical config transfer authority: %v", err)
}
authority := string(authoritySource)
for _, invariant := range []string{
"hasEnabledSSOProvidersForAuth(r.config)",
"r.hostedMode",
"r.ssoAuthenticationLoadFailed()",
"isDirectLoopbackRequest(req)",
"op == configTransferExport",
"getAPITokenRecordFromRequest(req)",
"ensureAdminSession(r.config, w, req)",
"config.ScopeSettingsRead",
"config.ScopeSettingsWrite",
} {
if !strings.Contains(authority, invariant) {
t.Fatalf("canonical config transfer authority missing %q", invariant)
}
}
}
type basicActionContractAuthorizer struct {
wantUser string
}
+9
View File
@@ -236,7 +236,10 @@ func (r *Router) ensureSSOConfig() *config.SSOConfig {
if r.persistence != nil {
cfg, err := r.persistence.LoadSSOConfig()
if err != nil {
r.ssoAuthLoadFailed.Store(true)
log.Error().Err(err).Msg("Failed to load SSO config from persistence")
} else {
r.ssoAuthLoadFailed.Store(false)
}
if cfg != nil {
r.ssoConfig = cfg
@@ -260,6 +263,10 @@ func (r *Router) ensureSSOConfig() *config.SSOConfig {
return r.ssoConfig
}
func (r *Router) ssoAuthenticationLoadFailed() bool {
return r != nil && r.ssoAuthLoadFailed.Load()
}
func (r *Router) handleListSSOProviders(w http.ResponseWriter, req *http.Request) {
r.ensureSSOConfig()
@@ -622,12 +629,14 @@ func (r *Router) handleDeleteSSOProvider(w http.ResponseWriter, req *http.Reques
func (r *Router) saveSSOConfig() error {
if r.persistence == nil {
setSSOAuthSnapshot(r.config, r.ssoConfig)
r.ssoAuthLoadFailed.Store(false)
return nil
}
if err := r.persistence.SaveSSOConfig(r.ssoConfig); err != nil {
return err
}
setSSOAuthSnapshot(r.config, r.ssoConfig)
r.ssoAuthLoadFailed.Store(false)
return nil
}
+6
View File
@@ -22,6 +22,7 @@ import (
"strconv"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
@@ -149,6 +150,7 @@ type Router struct {
oidcManager *OIDCServiceManager
samlManager *SAMLServiceManager
ssoConfig *config.SSOConfig
ssoAuthLoadFailed atomic.Bool
sessionStore *SessionStore
csrfStore *CSRFTokenStore
recoveryTokenStore *RecoveryTokenStore
@@ -335,6 +337,10 @@ func NewRouter(cfg *config.Config, monitor *monitoring.Monitor, mtMonitor *monit
// Initialize SSO service managers
r.oidcManager = NewOIDCServiceManager()
r.samlManager = NewSAMLServiceManager("")
// Load persisted and environment-backed SSO before routes begin serving.
// Configuration transfer and no-auth recovery must never observe the old
// lazy-loading window as an unauthenticated installation.
r.ensureSSOConfig()
if err := r.syncSAMLPublicURL(); err != nil {
log.Error().Err(err).Msg("Failed to initialize SAML public URL")
}
+6 -23
View File
@@ -82,17 +82,7 @@ type privilegedSecurityStatusResponse struct {
}
func (r *Router) securityAuthenticationConfigured() bool {
if r == nil || r.config == nil {
return false
}
if (r.config.AuthUser != "" && r.config.AuthPass != "") ||
r.config.HasAPITokens() ||
r.config.ProxyAuthSecret != "" ||
r.hostedMode {
return true
}
ssoCfg := r.ensureSSOConfig()
return ssoCfg != nil && ssoCfg.HasEnabledProviders()
return r.configTransferAuthenticationConfigured()
}
func (r *Router) authorizeSecurityRestart(w http.ResponseWriter, req *http.Request) bool {
@@ -342,18 +332,10 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
ssoProviders = append(ssoProviders, info)
}
requiresAuth := r.securityAuthenticationConfigured()
hasAuthentication := os.Getenv("PULSE_AUTH_USER") != "" ||
os.Getenv("REQUIRE_AUTH") == "true" ||
r.config.AuthUser != "" ||
r.config.AuthPass != "" ||
r.config.HasAPITokens() ||
r.config.ProxyAuthSecret != "" ||
r.hostedMode ||
hasEnabledSSO
requiresAuth := r.config.HasAPITokens() ||
(r.config.AuthUser != "" && r.config.AuthPass != "") ||
r.config.ProxyAuthSecret != "" ||
hasEnabledSSO
requiresAuth
publicStatus := publicSecurityStatusResponse{
DetailLevel: securityStatusDetailPublic,
@@ -435,12 +417,13 @@ func (r *Router) registerAuthSecurityInstallRoutes() {
authenticatedStatus.DetailLevel = securityStatusDetailPrivileged
authenticatedStatus.HasProxyAuth = hasProxyAuth
unprotectedExportAllowed := !r.configTransferAuthenticationConfigured() && os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true"
status := privilegedSecurityStatusResponse{
authenticatedSecurityStatusResponse: authenticatedStatus,
APITokenConfigured: r.config.HasAPITokens(),
APITokenHint: r.config.PrimaryAPITokenHint(),
ExportProtected: r.config.HasAPITokens() || os.Getenv("ALLOW_UNPROTECTED_EXPORT") != "true",
UnprotectedExportAllowed: os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true",
ExportProtected: !unprotectedExportAllowed,
UnprotectedExportAllowed: unprotectedExportAllowed,
ConfiguredButPendingRestart: configuredButPendingRestart,
HasAuditLogging: os.Getenv("PULSE_AUDIT_LOG") == "true" || os.Getenv("AUDIT_LOG_ENABLED") == "true",
CredentialsEncrypted: true,
+5 -250
View File
@@ -3,14 +3,12 @@ package api
import (
"encoding/json"
"net/http"
"os"
"strings"
"time"
"github.com/rcourtman/pulse-go-rewrite/internal/agentcapabilities"
"github.com/rcourtman/pulse-go-rewrite/internal/config"
"github.com/rcourtman/pulse-go-rewrite/internal/websocket"
"github.com/rs/zerolog/log"
)
const featureAgentProfilesKey = "agent_profiles"
@@ -428,135 +426,14 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
}
})
// Config export/import routes (requires authentication)
// Config export/import routes. These stay globally public so the deliberate
// no-auth recovery policy can operate; authorizeConfigTransfer is the single
// fail-closed boundary for every authenticated, hosted, and tenant mode.
r.mux.HandleFunc("/api/config/export", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Check proxy auth first
hasValidProxyAuth := false
proxyAuthIsAdmin := false
if r.config.ProxyAuthSecret != "" {
if valid, _, isAdmin := CheckProxyAuth(r.config, req); valid {
hasValidProxyAuth = true
proxyAuthIsAdmin = isAdmin
}
}
// Check authentication - accept proxy auth, session auth or API token
hasValidSession := false
sessionUsername := ""
sessionIsAdmin := false
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" {
hasValidSession = ValidateSession(cookie.Value)
if hasValidSession {
sessionUsername = strings.TrimSpace(GetSessionUsername(cookie.Value))
// Same admin test the settings routes apply. Comparing
// against r.config.AuthUser alone cannot match on an
// instance whose only administrators are SSO principals,
// which locked those operators out of their own config
// export and import.
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, sessionUsername)
}
}
validateAPIToken := func(token string) bool {
if token == "" || !r.config.HasAPITokens() {
return false
}
_, ok := r.config.ValidateAPIToken(token)
return ok
}
token := req.Header.Get("X-API-Token")
if token == "" {
if authHeader := req.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
}
hasValidAPIToken := validateAPIToken(token)
// Check if any valid auth method is present
hasValidAuth := hasValidProxyAuth || sessionIsAdmin || hasValidAPIToken
// Determine if auth is required
authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" ||
r.config.HasAPITokens() ||
r.config.ProxyAuthSecret != ""
// Check admin privileges for proxy auth users
if hasValidProxyAuth && !proxyAuthIsAdmin {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Non-admin proxy auth user attempted export/import")
http.Error(w, "Admin privileges required for export/import", http.StatusForbidden)
if !r.authorizeConfigTransfer(w, req, configTransferExport) {
return
}
if authRequired && hasValidSession && !sessionIsAdmin {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Str("user", sessionUsername).
Msg("Non-admin session user attempted export/import")
http.Error(w, "Admin privileges required for export/import", http.StatusForbidden)
return
}
if authRequired && !hasValidAuth {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Bool("proxyAuth", hasValidProxyAuth).
Bool("session", sessionIsAdmin).
Bool("apiToken", hasValidAPIToken).
Msg("Unauthorized export attempt")
http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized)
return
} else if !authRequired {
// No auth configured - check if this is a homelab/private network
clientIP := GetClientIP(req)
isPrivate := isPrivateIP(clientIP)
allowUnprotected := os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true"
if !isPrivate && !allowUnprotected {
// Public network access without auth - definitely block
log.Warn().
Str("ip", req.RemoteAddr).
Bool("private_network", isPrivate).
Msg("Export blocked - public network requires authentication")
http.Error(w, "Export requires authentication on public networks", http.StatusForbidden)
return
} else if isPrivate && !allowUnprotected {
// Private network but ALLOW_UNPROTECTED_EXPORT not set - show helpful message
log.Info().
Str("ip", req.RemoteAddr).
Msg("Export allowed - private network with no auth")
// Continue - allow export on private networks for homelab users
}
}
// SECURITY: Check settings:read scope for API token auth
if hasValidAPIToken && token != "" {
record, _ := r.config.ValidateAPIToken(token)
if record != nil && !record.HasScope(config.ScopeSettingsRead) {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Str("token_id", record.ID).
Msg("API token missing settings:read scope for export")
http.Error(w, "API token missing required scope: settings:read", http.StatusForbidden)
return
}
}
// Log successful export attempt
log.Info().
Str("ip", req.RemoteAddr).
Bool("proxy_auth", hasValidProxyAuth).
Bool("session_auth", sessionIsAdmin).
Bool("api_token_auth", hasValidAPIToken).
Msg("Configuration export initiated")
r.configHandlers.HandleExportConfig(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -565,131 +442,9 @@ func (r *Router) registerConfigSystemRoutes(updateHandlers *UpdateHandlers) {
r.mux.HandleFunc("/api/config/import", r.exportLimiter.Middleware(func(w http.ResponseWriter, req *http.Request) {
if req.Method == http.MethodPost {
// Check proxy auth first
hasValidProxyAuth := false
proxyAuthIsAdmin := false
if r.config.ProxyAuthSecret != "" {
if valid, _, isAdmin := CheckProxyAuth(r.config, req); valid {
hasValidProxyAuth = true
proxyAuthIsAdmin = isAdmin
}
}
// Check authentication - accept proxy auth, session auth or API token
hasValidSession := false
sessionUsername := ""
sessionIsAdmin := false
if cookie, err := readSessionCookie(req); err == nil && cookie.Value != "" {
hasValidSession = ValidateSession(cookie.Value)
if hasValidSession {
sessionUsername = strings.TrimSpace(GetSessionUsername(cookie.Value))
// Same admin test the settings routes apply. Comparing
// against r.config.AuthUser alone cannot match on an
// instance whose only administrators are SSO principals,
// which locked those operators out of their own config
// export and import.
sessionIsAdmin = sessionUserCarriesAdminPrivileges(r.config, sessionUsername)
}
}
validateAPIToken := func(token string) bool {
if token == "" || !r.config.HasAPITokens() {
return false
}
_, ok := r.config.ValidateAPIToken(token)
return ok
}
token := req.Header.Get("X-API-Token")
if token == "" {
if authHeader := req.Header.Get("Authorization"); strings.HasPrefix(authHeader, "Bearer ") {
token = strings.TrimPrefix(authHeader, "Bearer ")
}
}
hasValidAPIToken := validateAPIToken(token)
// Check if any valid auth method is present
hasValidAuth := hasValidProxyAuth || sessionIsAdmin || hasValidAPIToken
// Determine if auth is required
authRequired := r.config.AuthUser != "" && r.config.AuthPass != "" ||
r.config.HasAPITokens() ||
r.config.ProxyAuthSecret != ""
// Check admin privileges for proxy auth users
if hasValidProxyAuth && !proxyAuthIsAdmin {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Msg("Non-admin proxy auth user attempted export/import")
http.Error(w, "Admin privileges required for export/import", http.StatusForbidden)
if !r.authorizeConfigTransfer(w, req, configTransferImport) {
return
}
if authRequired && hasValidSession && !sessionIsAdmin {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Str("user", sessionUsername).
Msg("Non-admin session user attempted export/import")
http.Error(w, "Admin privileges required for export/import", http.StatusForbidden)
return
}
if authRequired && !hasValidAuth {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Bool("proxyAuth", hasValidProxyAuth).
Bool("session", sessionIsAdmin).
Bool("apiToken", hasValidAPIToken).
Msg("Unauthorized import attempt")
http.Error(w, "Unauthorized - please log in or provide API token", http.StatusUnauthorized)
return
} else if !authRequired {
// No auth configured - check if this is a homelab/private network
clientIP := GetClientIP(req)
isPrivate := isPrivateIP(clientIP)
allowUnprotected := os.Getenv("ALLOW_UNPROTECTED_EXPORT") == "true"
if !isPrivate && !allowUnprotected {
// Public network access without auth - definitely block
log.Warn().
Str("ip", req.RemoteAddr).
Bool("private_network", isPrivate).
Msg("Import blocked - public network requires authentication")
http.Error(w, "Import requires authentication on public networks", http.StatusForbidden)
return
} else if isPrivate && !allowUnprotected {
// Private network but ALLOW_UNPROTECTED_EXPORT not set - show helpful message
log.Info().
Str("ip", req.RemoteAddr).
Msg("Import allowed - private network with no auth")
// Continue - allow import on private networks for homelab users
}
}
// SECURITY: Check settings:write scope for API token auth
if hasValidAPIToken && token != "" {
record, _ := r.config.ValidateAPIToken(token)
if record != nil && !record.HasScope(config.ScopeSettingsWrite) {
log.Warn().
Str("ip", req.RemoteAddr).
Str("path", req.URL.Path).
Str("token_id", record.ID).
Msg("API token missing settings:write scope for import")
http.Error(w, "API token missing required scope: settings:write", http.StatusForbidden)
return
}
}
// Log successful import attempt
log.Info().
Str("ip", req.RemoteAddr).
Bool("session_auth", sessionIsAdmin).
Bool("api_token_auth", hasValidAPIToken).
Msg("Configuration import initiated")
r.configHandlers.HandleImportConfig(w, req)
} else {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -30,6 +31,34 @@ func setupTelemetryTest(t *testing.T, cfg *config.Config) (*SystemSettingsHandle
return handler, persistence, tokenVal
}
func TestSecurityOperatorDocsPublishEffectiveConfigTransferPolicy(t *testing.T) {
rootSecurity, err := os.ReadFile(filepath.Clean("../../SECURITY.md"))
if err != nil {
t.Fatalf("read root security guide: %v", err)
}
shippedSecurity, err := os.ReadFile(filepath.Clean("../../frontend-modern/public/docs/SECURITY.md"))
if err != nil {
t.Fatalf("read shipped security guide: %v", err)
}
if !bytes.Equal(rootSecurity, shippedSecurity) {
t.Fatal("shipped security guide drifted from the canonical root guide")
}
guide := strings.Join(strings.Fields(string(rootSecurity)), " ")
for _, policy := range []string{
"only to configuration export",
"It never enables import",
"direct loopback connection",
"private-network and forwarded requests are not loopback",
"settings:read",
"settings:write",
"organization-bound tokens",
} {
if !strings.Contains(guide, policy) {
t.Fatalf("security guide missing config transfer policy %q", policy)
}
}
}
func TestTelemetryUpdate_EnvLockRejects(t *testing.T) {
tempDir := t.TempDir()
cfg := &config.Config{