Integrate reviewed origin trust and webhook credential repairs

Change-source: pulse-maintainer
This commit is contained in:
pulse-triage[bot]
2026-10-06 11:52:28 +01:00
9 changed files with 584 additions and 78 deletions
@@ -15,6 +15,19 @@
## Purpose
### Shared origin and webhook edit boundaries
The referenced `internal/api/` settings boundary treats explicit empty
`allowedOrigins` as a durable clear and omitted values as unchanged. Effective
GET values and deployment-owned overrides remain consistent; failed/rejected
saves cannot change live trust. This does not change agent identity, enrolment,
credential scope, execution permission, installer transport or TLS policy.
Notification Create/Update/Test consistently reject unresolved saved masks and
conflicting HTTP header identities before mutation or sending; literal edits,
masked responses and persist-before-publish remain intact. Source verification
uses `TestAllowedOrigins*` and `TestWebhookPlaceholderBoundaryAgreement` plus
the existing settings/notification authority and ordinary-delivery controls.
### Cluster installation keyboard continuity
`ClusterAgentDeployDialog` keeps one mounted dismissal button while loading,
@@ -20,6 +20,39 @@
## Purpose
### Origin-trust reversal and consistent webhook placeholders
`POST /api/system/settings/update` treats the presence of a string
`allowedOrigins` as a patch: an explicit empty string removes saved and runtime
CORS trust, while omission changes neither. Null is not an empty-string clear.
Persist first; a failed save changes no runtime policy or settings cache.
`GET /api/system/settings` reports the effective value. Deployment-owned origins
remain authoritative: changing them returns `409 env_locked` before any save;
the form's unchanged locked value does not replace the underlying saved value
or prevent other settings edits. Authentication, settings scopes, CSRF, TLS,
comma-separated exact-origin matching and embedding policy stay independent.
`TestAllowedOriginsPatchBoundaries`, `TestAllowedOriginsSavedEffectiveLifecycle`,
`TestAllowedOriginsSaveFailurePreservesPolicy` and
`TestAllowedOriginsGetUsesDeploymentPolicy` bind these boundaries to the real
handlers, middleware, persisted settings, settings reload and production config
loader. This is source acceptance, not an installed deployment observation.
Webhook Create, Update and saved-form Test use one masked-value resolver.
`***REDACTED***` means keep the corresponding saved value, not omit the field:
missing, masked or conflicting saved identities return `400` before saving,
live publication or a Test send. HTTP header names share case-insensitive
identity, including aliases alongside an exact match; custom-field keys remain
case-sensitive. Conflicting submitted header aliases are also rejected.
A masked signing secret needs a non-empty, non-placeholder saved secret.
Explicit additions/changes, omitted-map replacement/removals and blank-header
normalization retain the existing semantics; list/create/update responses stay
masked. Unsaved forms must supply literal values rather than saved masks.
`TestWebhookPlaceholderBoundaryAgreement` and
`TestWebhookSavedFormTestMatchesEncryptedEdit` exercise every entry point and a
real Test request plus encrypted edit/readback. Existing ordinary-delivery
integration retains grouped/ungrouped firing, resolution and restart controls;
these do not establish native Telegram acceptance or alter old queued jobs.
### Existing disk History keeps native thermal fallback reachable
The authenticated disk History endpoint supplements a missing or shallow
@@ -15,6 +15,28 @@
## Purpose
### Saved webhook identity is consistent for editing and Test
Create, Update and Test share one resolver for masked header/custom-field and
signing-secret values. Reject missing or ambiguous saved placeholders before
persisting, changing live destinations or sending a test request. HTTP header
identity is case-insensitive across the complete saved map, even where an exact
spelling is present; conflicting submitted aliases cannot depend on map order.
Custom fields retain exact, case-sensitive identity. A saved signing secret
must be non-empty and not itself a placeholder to satisfy a signing mask.
The replacement map still honours explicit add/change/delete and omitted keys;
cleared headers are normalized away so the sender's JSON default survives.
List/create/update responses remain masked. Create and unsaved Test reject masks
instead of silently dropping credentials; a valid literal create still returns
masked values. Saved-form Test uses the same resolved edit as Update but does
not change live configuration or encrypted persistence. Persist-before-publish,
SSRF/TLS policy, routing/scopes, notification pause honesty and already-admitted
queue snapshots remain unchanged. Source controls
`TestWebhookPlaceholderBoundaryAgreement` and
`TestWebhookSavedFormTestMatchesEncryptedEdit` complement the existing real
ordinary-delivery/restart/grouping controls; none proves native #2540 relief.
### Delivery health reconciliation follows the complete send transition
The queue's automatic send worker records the retry/dead-letter outcome and
@@ -18,6 +18,26 @@
## Purpose
### Removing browser-origin trust is a durable explicit patch
The system settings handler accepts an explicit empty string for
`allowedOrigins` and applies it only after the durable save succeeds. Omitted
origins do not reapply a different saved value to the runtime. Null and
non-string values are invalid requests. GET presents the effective runtime
policy, not a saved value concealed by a deployment override.
Either existing override marker (`ALLOWED_ORIGINS` or `allowedOrigins`) keeps
the deployment policy authoritative. A requested different value is rejected
with `409 env_locked` before any part of the patch is persisted. The unchanged
value sent by a disabled form field leaves underlying disk policy untouched
and permits other settings to be saved. Clearing origins grants no CORS headers;
production config reload does not revive removed trust. No change to the
existing development localhost fallback or deployment environment precedence
is made. CORS never replaces authentication, `settings:write`, CSRF, TLS or
iframe policy. The `TestAllowedOrigins*` controls in
`internal/api/system_settings_telemetry_test.go` cover real middleware responses,
exact-list matching, protected writes, rejected/failed saves and reload.
Assistant retained-history wiring uses the store belonging to its current
monitor and read-only metric queries. Canonical identity resolution precedes
store lookup, and ambiguous names fail with a request for canonical identity.
@@ -21,6 +21,22 @@
## Purpose
### Persisted trust edits and restart consistency
The shared `internal/api/` settings path durably saves an explicit empty
`allowedOrigins` before clearing live CORS policy. An omitted field does not
replace a different effective value; a failed or deployment-locked patch does
not partially change disk/runtime/cache state. Production config reload retains
the saved clear while existing deployment overrides stay authoritative.
Webhook edits resolve every saved placeholder before persistence or live
publication, and saved-form Test uses the same resolution without changing
configuration. Unknown/ambiguous placeholders fail rather than silently lose a
credential. `TestAllowedOriginsSavedEffectiveLifecycle`,
`TestAllowedOriginsSaveFailurePreservesPolicy` and
`TestWebhookSavedFormTestMatchesEncryptedEdit` cover these shared boundaries.
No database/driver/schema, backup recovery, retention or old queue snapshot
policy changes are part of this repair.
### Guest-memory read state stays independent of backup disk state
The existing guest drawer retains Core's selected memory provenance through
+59 -60
View File
@@ -422,41 +422,26 @@ func maskedWebhookResponse(webhook notifications.WebhookConfig) map[string]inter
return whMap
}
// restoreMaskedWebhookSecrets replaces each masked value in an incoming
// webhook with the saved value of the same header, custom field or signing
// secret. Restoring per key keeps any value the user typed in the same edit;
// a masked key with no saved value is dropped, so the mask is never stored or
// sent as a real value.
func restoreMaskedWebhookSecrets(incoming, saved notifications.WebhookConfig) notifications.WebhookConfig {
incoming.Headers = restoreMaskedWebhookValues(incoming.Headers, saved.Headers)
incoming.CustomFields = restoreMaskedWebhookValues(incoming.CustomFields, saved.CustomFields)
// restoreMaskedWebhookSecrets is the shared Create/Update/Test policy. A mask
// means keep this saved value, not discard the field or store the placeholder.
// Reject missing/ambiguous saved identities before persistence or any test send.
func restoreMaskedWebhookSecrets(incoming, saved notifications.WebhookConfig) (notifications.WebhookConfig, error) {
var err error
incoming.Headers, err = restoreStrictMaskedWebhookValues(incoming.Headers, saved.Headers, true)
if err != nil {
return notifications.WebhookConfig{}, err
}
incoming.CustomFields, err = restoreStrictMaskedWebhookValues(incoming.CustomFields, saved.CustomFields, false)
if err != nil {
return notifications.WebhookConfig{}, err
}
if incoming.SigningSecret == maskedWebhookSecret {
if saved.SigningSecret == "" || saved.SigningSecret == maskedWebhookSecret {
return notifications.WebhookConfig{}, fmt.Errorf("no saved value for masked signing secret")
}
incoming.SigningSecret = saved.SigningSecret
}
return incoming
}
func restoreMaskedWebhookValues(incoming, saved map[string]string) map[string]string {
if len(incoming) == 0 {
return incoming
}
restored := make(map[string]string, len(incoming))
for key, value := range incoming {
if value != maskedWebhookSecret {
restored[key] = value
continue
}
if savedValue, ok := saved[key]; ok {
restored[key] = savedValue
}
}
return restored
}
// stripMaskedWebhookSecrets drops masked values from a webhook that has no
// saved counterpart, such as a create or an unsaved form test.
func stripMaskedWebhookSecrets(webhook notifications.WebhookConfig) notifications.WebhookConfig {
return restoreMaskedWebhookSecrets(webhook, notifications.WebhookConfig{})
return notifications.NormalizeWebhookConfig(incoming), nil
}
// CreateWebhook creates a new webhook
@@ -479,7 +464,11 @@ func (h *NotificationHandlers) CreateWebhook(w http.ResponseWriter, r *http.Requ
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
webhook = notifications.NormalizeWebhookConfig(stripMaskedWebhookSecrets(webhook))
webhook, err = restoreMaskedWebhookSecrets(webhook, notifications.WebhookConfig{})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
monitor := h.getMonitor(r.Context())
manager := monitor.GetNotificationManager()
@@ -518,25 +507,42 @@ func restoreStrictMaskedWebhookValues(incoming, existing map[string]string, head
if incoming == nil {
return nil, nil
}
resolved := make(map[string]string, len(incoming))
for key, value := range incoming {
if value == "***REDACTED***" {
stored, found := existing[key]
if !found && headers {
for oldKey, oldValue := range existing {
if strings.EqualFold(oldKey, key) {
if found && stored != oldValue {
return nil, fmt.Errorf("ambiguous saved value for masked header %q", key)
}
stored, found = oldValue, true
}
}
// Build the full HTTP identity index, even if an exact spelling exists:
// a legacy case-varied alias must not override or conceal a conflicting value.
savedHeaders := make(map[string]string)
ambiguousHeaders := make(map[string]bool)
if headers {
for key, value := range existing {
identity := strings.ToLower(key)
if previous, found := savedHeaders[identity]; found && previous != value {
ambiguousHeaders[identity] = true
}
if !found || stored == "***REDACTED***" {
savedHeaders[identity] = value
}
}
resolved := make(map[string]string, len(incoming))
incomingHeaders := make(map[string]string)
for key, value := range incoming {
identity := strings.ToLower(key)
if value == maskedWebhookSecret {
stored, found := existing[key]
if headers {
if ambiguousHeaders[identity] {
return nil, fmt.Errorf("ambiguous saved value for masked header %q", key)
}
stored, found = savedHeaders[identity]
}
if !found || stored == maskedWebhookSecret {
return nil, fmt.Errorf("no saved value for masked field %q", key)
}
value = stored
}
if headers {
if previous, found := incomingHeaders[identity]; found && previous != value {
return nil, fmt.Errorf("ambiguous submitted values for header %q", key)
}
incomingHeaders[identity] = value
}
resolved[key] = value
}
return resolved, nil
@@ -578,7 +584,6 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
MinimumSeverity *string `json:"minimumSeverity"`
}
_ = json.Unmarshal(bodyBytes, &routingPresence)
webhook = notifications.NormalizeWebhookConfig(webhook)
monitor := h.getMonitor(r.Context())
manager := monitor.GetNotificationManager()
@@ -598,20 +603,11 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
if routingPresence.MinimumSeverity == nil {
webhook.MinimumSeverity = existing.MinimumSeverity
}
webhook.Headers, err = restoreStrictMaskedWebhookValues(webhook.Headers, existing.Headers, true)
webhook, err = restoreMaskedWebhookSecrets(webhook, existing)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
webhook.CustomFields, err = restoreStrictMaskedWebhookValues(webhook.CustomFields, existing.CustomFields, false)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Preserve the signing secret if the incoming value is redacted
if webhook.SigningSecret == "***REDACTED***" {
webhook.SigningSecret = existing.SigningSecret
}
break
}
}
@@ -619,7 +615,6 @@ func (h *NotificationHandlers) UpdateWebhook(w http.ResponseWriter, r *http.Requ
http.Error(w, fmt.Sprintf("webhook not found: %s", webhookID), http.StatusNotFound)
return
}
webhook = notifications.NormalizeWebhookConfig(webhook)
// Validate webhook URL
if err := manager.ValidateWebhookURL(webhook.URL); err != nil {
@@ -962,7 +957,11 @@ func (h *NotificationHandlers) TestWebhook(w http.ResponseWriter, r *http.Reques
}
}
}
basicWebhook = notifications.NormalizeWebhookConfig(restoreMaskedWebhookSecrets(basicWebhook, saved))
basicWebhook, err = restoreMaskedWebhookSecrets(basicWebhook, saved)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// Try to extract service from body if present
var serviceCheck struct {
+180 -16
View File
@@ -1603,6 +1603,8 @@ func TestWebhookMaskedEditPersistsAndDelivers(t *testing.T) {
if edit.Headers["Authorization"] != "***REDACTED***" || edit.CustomFields["token"] != "***REDACTED***" {
t.Fatal("list disclosed credentials")
}
edit.Headers["authorization"] = edit.Headers["Authorization"]
delete(edit.Headers, "Authorization")
edit.Headers["Content-Type"], edit.Headers["X-Route"] = "application/json", "new"
delete(edit.Headers, "X-Remove")
edit.CustomFields["channel"] = "new"
@@ -1624,7 +1626,7 @@ func TestWebhookMaskedEditPersistsAndDelivers(t *testing.T) {
monitor.manager = m
saved := m.GetWebhooks()[0]
if saved.Service != service || saved.Template != hook.Template || saved.URL != hook.URL ||
saved.Headers["Authorization"] != "synthetic-auth" || saved.Headers["Content-Type"] != "application/json" ||
saved.Headers["authorization"] != "synthetic-auth" || saved.Headers["Content-Type"] != "application/json" ||
saved.Headers["X-Route"] != "new" || len(saved.Headers) != 3 ||
saved.CustomFields["token"] != "synthetic-token" || saved.CustomFields["channel"] != "new" || len(saved.CustomFields) != 2 {
t.Fatal("masked edit did not survive encrypted reload with explicit edits/removals intact")
@@ -1819,7 +1821,7 @@ func TestWebhookMaskedValuesResolvePerKey(t *testing.T) {
assert.Equal(t, "", published.SigningSecret, "an omitted signing secret is removed, as before")
})
t.Run("create drops masked values and responds masked", func(t *testing.T) {
t.Run("create keeps literal values and responds masked", func(t *testing.T) {
h, manager, persistence := newHandlers()
var added notifications.WebhookConfig
manager.On("ValidateWebhookURL", "https://hooks.example.com/new").Return(nil).Once()
@@ -1831,7 +1833,7 @@ func TestWebhookMaskedValuesResolvePerKey(t *testing.T) {
body, _ := json.Marshal(map[string]interface{}{
"name": "New", "url": "https://hooks.example.com/new", "enabled": true,
"headers": map[string]string{"Authorization": "***REDACTED***", "X-Api-Key": "typed-key"},
"headers": map[string]string{"X-Api-Key": "typed-key"},
"signingSecret": "typed-signing-secret",
})
rec := httptest.NewRecorder()
@@ -1872,25 +1874,187 @@ func TestWebhookMaskedValuesResolvePerKey(t *testing.T) {
assert.Equal(t, "saved-signing-secret", tested.SigningSecret)
})
t.Run("form test of an unsaved webhook never sends the mask", func(t *testing.T) {
t.Run("form test of an unsaved webhook rejects the mask before sending", func(t *testing.T) {
h, manager, _ := newHandlers()
var tested notifications.EnhancedWebhookConfig
manager.On("TestEnhancedWebhook", mock.Anything).Run(func(args mock.Arguments) {
tested = args.Get(0).(notifications.EnhancedWebhookConfig)
}).Return(200, "OK", nil).Once()
manager.On("IsEnabled").Return(true).Once()
body, _ := json.Marshal(map[string]interface{}{
"url": "https://hooks.example.com/new",
"headers": map[string]string{"Authorization": "***REDACTED***", "X-Team": "platform"},
"headers": map[string]string{"Authorization": maskedWebhookSecret, "X-Team": "platform"},
})
rec := httptest.NewRecorder()
h.TestWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
assert.Equal(t, http.StatusOK, rec.Code)
_, hasAuthorization := tested.Headers["Authorization"]
assert.False(t, hasAuthorization, "a mask with no saved value must not be sent")
assert.Equal(t, "platform", tested.Headers["X-Team"])
assert.Equal(t, http.StatusBadRequest, rec.Code)
manager.AssertNotCalled(t, "TestEnhancedWebhook", mock.Anything)
manager.AssertNotCalled(t, "GetWebhooks")
})
}
// A masked form must fail before persistence or a Test send if its saved
// identity is missing or ambiguous. Test and Update must resolve the same map.
func TestWebhookPlaceholderBoundaryAgreement(t *testing.T) {
for _, route := range []string{"update", "test", "create"} {
for _, tc := range []struct {
name string
savedHeaders, incomingHeaders, savedFields, incomingFields map[string]string
savedSigning, incomingSigning string
valid bool
}{
{"case varied saved header", map[string]string{"Authorization": "synthetic-auth"}, map[string]string{"authorization": maskedWebhookSecret}, nil, nil, "", "", true},
{"unknown header", nil, map[string]string{"X-Missing": maskedWebhookSecret}, nil, nil, "", "", false},
{"stored header is a mask", map[string]string{"Authorization": maskedWebhookSecret}, map[string]string{"Authorization": maskedWebhookSecret}, nil, nil, "", "", false},
{"exact match also has conflicting alias", map[string]string{"Authorization": "synthetic-one", "authorization": "synthetic-two"}, map[string]string{"Authorization": maskedWebhookSecret}, nil, nil, "", "", false},
{"incoming aliases conflict", nil, map[string]string{"X-Route": "one", "x-route": "two"}, nil, nil, "", "", false},
{"custom fields are case sensitive", nil, nil, map[string]string{"Token": "synthetic-token"}, map[string]string{"token": maskedWebhookSecret}, "", "", false},
{"missing signing secret", nil, nil, nil, nil, "", maskedWebhookSecret, false},
{"saved signing secret is a mask", nil, nil, nil, nil, maskedWebhookSecret, maskedWebhookSecret, false},
} {
if route == "create" && tc.valid {
continue
}
t.Run(route+"/"+tc.name, func(t *testing.T) {
manager := new(MockNotificationManager)
persistence := new(MockNotificationConfigPersistence)
monitor := new(MockNotificationMonitor)
monitor.On("GetNotificationManager").Return(manager)
monitor.On("GetConfigPersistence").Return(persistence)
saved := notifications.WebhookConfig{ID: "saved", URL: "https://hooks.example.test/alerts", Headers: tc.savedHeaders, CustomFields: tc.savedFields, SigningSecret: tc.savedSigning}
manager.On("GetWebhooks").Return([]notifications.WebhookConfig{saved}).Maybe()
manager.On("ValidateWebhookURL", mock.Anything).Return(nil).Maybe()
manager.On("IsEnabled").Return(true).Maybe()
manager.On("UpdateWebhook", mock.Anything, mock.Anything).Return(nil).Maybe()
manager.On("AddWebhook", mock.Anything).Return().Maybe()
persistence.On("SaveWebhooks", mock.Anything).Return(nil).Maybe()
var tested notifications.EnhancedWebhookConfig
manager.On("TestEnhancedWebhook", mock.Anything).Run(func(a mock.Arguments) { tested = a.Get(0).(notifications.EnhancedWebhookConfig) }).Return(200, "OK", nil).Maybe()
incoming := notifications.WebhookConfig{ID: "saved", URL: saved.URL, Headers: tc.incomingHeaders, CustomFields: tc.incomingFields, SigningSecret: tc.incomingSigning}
body, err := json.Marshal(incoming)
if err != nil {
t.Fatal(err)
}
rec := httptest.NewRecorder()
h := NewNotificationHandlers(nil, monitor)
switch route {
case "create":
h.CreateWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks", bytes.NewReader(body)))
case "update":
h.UpdateWebhook(rec, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/saved", bytes.NewReader(body)))
case "test":
h.TestWebhook(rec, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
}
if tc.valid {
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if route == "test" && tested.Headers["authorization"] != "synthetic-auth" {
t.Fatal("Test silently dropped the case-varied saved credential")
}
} else {
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400", rec.Code)
}
manager.AssertNotCalled(t, "TestEnhancedWebhook", mock.Anything)
manager.AssertNotCalled(t, "UpdateWebhook", mock.Anything, mock.Anything)
manager.AssertNotCalled(t, "AddWebhook", mock.Anything)
persistence.AssertNotCalled(t, "SaveWebhooks", mock.Anything)
}
for _, secret := range []string{"synthetic-auth", "synthetic-one", "synthetic-two", "synthetic-token"} {
if strings.Contains(rec.Body.String(), secret) {
t.Error("response disclosed a synthetic saved value")
}
}
})
}
}
}
func TestWebhookSavedFormTestMatchesEncryptedEdit(t *testing.T) {
type observation struct {
header http.Header
payload string
}
var mu sync.Mutex
var observed []observation
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var payload json.RawMessage
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
w.WriteHeader(400)
return
}
mu.Lock()
observed = append(observed, observation{r.Header.Clone(), string(payload)})
mu.Unlock()
fmt.Fprint(w, "OK")
}))
defer server.Close()
dir := t.TempDir()
persistence := config.NewConfigPersistence(dir)
saved := notifications.WebhookConfig{ID: "saved-form", Name: "Ops", URL: server.URL, Enabled: true, Service: "generic", Headers: map[string]string{"Authorization": "synthetic-auth", "Content-Type": "text/plain", "X-Delete": "remove"}, CustomFields: map[string]string{"token": "synthetic-token", "channel": "old", "delete": "remove"}, SigningSecret: "synthetic-signing"}
if err := persistence.SaveWebhooks([]notifications.WebhookConfig{saved}); err != nil {
t.Fatal(err)
}
manager := notifications.NewNotificationManagerWithDeferredQueue("", dir)
defer manager.Stop()
if err := manager.UpdateAllowedPrivateCIDRs("127.0.0.1/32,::1/128"); err != nil {
t.Fatal(err)
}
manager.AddWebhook(saved)
liveBefore := manager.GetWebhooks()[0]
h := NewNotificationHandlers(nil, &webhookEditMonitor{manager, persistence})
listed := httptest.NewRecorder()
h.GetWebhooks(listed, httptest.NewRequest(http.MethodGet, "/api/notifications/webhooks", nil))
var editable []notifications.WebhookConfig
if err := json.Unmarshal(listed.Body.Bytes(), &editable); err != nil || len(editable) != 1 {
t.Fatal("masked saved form unavailable")
}
form := editable[0]
form.Headers["authorization"] = form.Headers["Authorization"]
delete(form.Headers, "Authorization")
form.Headers["Content-Type"] = ""
delete(form.Headers, "X-Delete")
form.Headers["X-New"] = "new"
form.CustomFields["channel"] = "new"
delete(form.CustomFields, "delete")
body, err := json.Marshal(form)
if err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(filepath.Join(dir, "webhooks.enc"))
if err != nil {
t.Fatal(err)
}
tested := httptest.NewRecorder()
h.TestWebhook(tested, httptest.NewRequest(http.MethodPost, "/api/notifications/webhooks/test", bytes.NewReader(body)))
if tested.Code != http.StatusOK {
t.Fatalf("saved form test: %d", tested.Code)
}
after, err := os.ReadFile(filepath.Join(dir, "webhooks.enc"))
if err != nil || !bytes.Equal(before, after) || !reflect.DeepEqual(manager.GetWebhooks()[0], liveBefore) {
t.Fatal("form Test mutated durable or live configuration")
}
mu.Lock()
got := append([]observation(nil), observed...)
mu.Unlock()
if len(got) != 1 || got[0].header.Get("Authorization") != "synthetic-auth" || got[0].header.Get("Content-Type") != "application/json" || got[0].header.Get("X-New") != "new" || got[0].header.Get("X-Delete") != "" || strings.Contains(got[0].payload, maskedWebhookSecret) {
t.Fatal("real Test did not honour saved identities, explicit edits/removals and JSON default")
}
updated := httptest.NewRecorder()
h.UpdateWebhook(updated, httptest.NewRequest(http.MethodPut, "/api/notifications/webhooks/saved-form", bytes.NewReader(body)))
if updated.Code != http.StatusOK {
t.Fatalf("saved form update: %d", updated.Code)
}
loaded, err := persistence.LoadWebhooks()
if err != nil || len(loaded) != 1 {
t.Fatal("encrypted edit unavailable after reload")
}
if loaded[0].Headers["authorization"] != "synthetic-auth" || loaded[0].Headers["X-New"] != "new" || len(loaded[0].Headers) != 2 || loaded[0].CustomFields["token"] != "synthetic-token" || loaded[0].CustomFields["channel"] != "new" || len(loaded[0].CustomFields) != 2 || loaded[0].SigningSecret != "synthetic-signing" {
t.Fatal("Test and persisted edit resolved different identities")
}
for _, response := range []string{listed.Body.String(), updated.Body.String(), tested.Body.String()} {
for _, secret := range []string{"synthetic-auth", "synthetic-token", "synthetic-signing"} {
if strings.Contains(response, secret) {
t.Fatal("configuration response disclosed a synthetic saved secret")
}
}
}
// The existing ordinary-delivery integration separately covers restart,
// grouped/un-grouped firing and resolution using the saved destination.
}
+20 -2
View File
@@ -380,6 +380,12 @@ func applyDiscoveryConfigOverrides(current config.DiscoveryConfig, cfgMap map[st
// validateSystemSettings validates settings before applying them
func validateSystemSettings(_ *config.SystemSettings, rawRequest map[string]interface{}) error {
if value, present := rawRequest["allowedOrigins"]; present {
if _, ok := value.(string); !ok {
return fmt.Errorf("allowedOrigins must be a string (use an empty string for same-origin only)")
}
}
if val, ok := rawRequest["reportBranding"]; ok {
settings, ok := val.(map[string]interface{})
if !ok {
@@ -713,6 +719,8 @@ func (h *SystemSettingsHandler) HandleGetSystemSettings(w http.ResponseWriter, r
Msg("Loaded system settings for API response")
settings.UpdateChannel = config.EffectiveUpdateChannel(settings.UpdateChannel, h.config.UpdateChannel)
// Expose the effective policy, including deployment overrides and clears.
settings.AllowedOrigins = h.config.AllowedOrigins
// Always expose effective backup polling configuration
settings.PVEPollingInterval = int(h.config.PVEPollingInterval.Seconds())
settings.BackupPollingInterval = int(h.config.BackupPollingInterval.Seconds())
@@ -866,6 +874,16 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
return
}
// A disabled deployment-owned field is still included by the settings form.
// Accept an unchanged value without copying the override into system.json;
// reject attempts to change it, before persisting any part of the patch.
_, allowedOriginsUpdated := rawRequest["allowedOrigins"]
allowedOriginsLocked := h.config.EnvOverrides["ALLOWED_ORIGINS"] || h.config.EnvOverrides["allowedOrigins"]
if allowedOriginsUpdated && allowedOriginsLocked && updates.AllowedOrigins != h.config.AllowedOrigins {
writeErrorResponse(w, http.StatusConflict, "env_locked", "allowedOrigins is locked by the ALLOWED_ORIGINS environment variable", nil)
return
}
// Start with existing settings
settings := *existingSettings
discoveryConfigUpdated := false
@@ -886,7 +904,7 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
if _, ok := rawRequest["backupPollingInterval"]; ok {
settings.BackupPollingInterval = updates.BackupPollingInterval
}
if updates.AllowedOrigins != "" {
if allowedOriginsUpdated && !allowedOriginsLocked {
settings.AllowedOrigins = updates.AllowedOrigins
}
if _, ok := rawRequest["connectionTimeout"]; ok {
@@ -1026,7 +1044,7 @@ func (h *SystemSettingsHandler) HandleUpdateSystemSettings(w http.ResponseWriter
}
h.config.PVEPollingInterval = newInterval
}
if settings.AllowedOrigins != "" {
if allowedOriginsUpdated && !allowedOriginsLocked {
h.config.AllowedOrigins = settings.AllowedOrigins
}
if settings.ConnectionTimeout > 0 {
@@ -748,3 +748,224 @@ func TestIssue1638SettingsSaveResetsSSHFailureBackoff(t *testing.T) {
t.Fatalf("settings save called ResetSSHFailureBackoff %d times, want 1", monitor.resetSSHFailureBackoffCalls)
}
}
// Clearing browser trust must be an explicit patch, not a falsy-value no-op.
func TestAllowedOriginsPatchBoundaries(t *testing.T) {
for _, tc := range []struct {
name, saved, effective, body, wantSaved, wantEffective string
override string
wantStatus int
}{
{"clear wildcard", "*", "*", `{"allowedOrigins":""}`, "", "", "", http.StatusOK},
{"clear exact origins", "https://old.example", "https://old.example", `{"allowedOrigins":""}`, "", "", "", http.StatusOK},
{"omission keeps effective policy", "https://saved.example", "https://effective.example", `{"theme":"dark"}`, "https://saved.example", "https://effective.example", "", http.StatusOK},
{"null is not a clear", "*", "*", `{"allowedOrigins":null}`, "*", "*", "", http.StatusBadRequest},
{"deployment lock uppercase", "https://saved.example", "https://deploy.example", `{"allowedOrigins":""}`, "https://saved.example", "https://deploy.example", "ALLOWED_ORIGINS", http.StatusConflict},
{"deployment lock field name", "https://saved.example", "https://deploy.example", `{"allowedOrigins":"*"}`, "https://saved.example", "https://deploy.example", "allowedOrigins", http.StatusConflict},
{"unchanged locked form saves other fields", "https://saved.example", "https://deploy.example", `{"allowedOrigins":"https://deploy.example","theme":"dark"}`, "https://saved.example", "https://deploy.example", "allowedOrigins", http.StatusOK},
} {
t.Run(tc.name, func(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{DataPath: dir, ConfigPath: dir, AllowedOrigins: tc.effective, EnvOverrides: map[string]bool{}}
if tc.override != "" {
cfg.EnvOverrides[tc.override] = true
}
h, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
initial.AllowedOrigins = tc.saved
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
reloads := 0
h.reloadSystemSettingsFunc = func() { reloads++ }
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(tc.body))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
h.HandleUpdateSystemSettings(rec, req)
if rec.Code != tc.wantStatus {
t.Fatalf("status = %d, want %d", rec.Code, tc.wantStatus)
}
saved, err := persistence.LoadSystemSettings()
if err != nil || saved == nil {
t.Fatalf("read saved policy: %v", err)
}
if saved.AllowedOrigins != tc.wantSaved || cfg.AllowedOrigins != tc.wantEffective {
t.Fatal("saved/effective policy did not honour field presence and deployment precedence")
}
if tc.wantStatus != http.StatusOK && reloads != 0 {
t.Fatal("rejected patch reloaded runtime state")
}
if tc.wantStatus == http.StatusOK && reloads != 1 {
t.Fatal("successful patch did not reload settings")
}
if tc.name == "unchanged locked form saves other fields" && saved.Theme != "dark" {
t.Fatal("locked value prevented an unrelated settings save")
}
})
}
}
// Exercise the production middleware and handlers, disk, settings-cache reload
// and the production config loader; no simulated setter stands in for policy.
func TestAllowedOriginsSavedEffectiveLifecycle(t *testing.T) {
for _, original := range []string{"*", "https://old.example"} {
t.Run(original, func(t *testing.T) {
dir := t.TempDir()
t.Setenv("PULSE_DATA_DIR", dir)
t.Setenv("ALLOWED_ORIGINS", "")
t.Setenv("PULSE_DEV", "false")
t.Setenv("NODE_ENV", "production")
InitPersistentAuthStores(dir)
t.Cleanup(resetSessionStoreForTests)
t.Cleanup(resetCSRFStoreForTests)
cfg := &config.Config{DataPath: dir, ConfigPath: dir, AllowedOrigins: original, PublicURL: "https://pulse.example", AuthUser: "admin", AuthPass: "synthetic-unused-hash", EnvOverrides: map[string]bool{}, TLSCertFile: "synthetic-cert", TLSKeyFile: "synthetic-key"}
h, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
initial.AllowedOrigins = original
initial.AllowedEmbedOrigins = "https://frame.example"
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
router := &Router{config: cfg, persistence: persistence, mux: http.NewServeMux()}
h.reloadSystemSettingsFunc = router.reloadSystemSettings
router.mux.HandleFunc("/api/system/settings", RequireAdmin(cfg, RequireScope(config.ScopeSettingsRead, h.HandleGetSystemSettings)))
router.mux.HandleFunc("/api/system/settings/update", RequireAdmin(cfg, RequireScope(config.ScopeSettingsWrite, h.HandleUpdateSystemSettings)))
router.reloadSystemSettings()
request := func(method, path, origin, body, credential string) *httptest.ResponseRecorder {
req := httptest.NewRequest(method, path, strings.NewReader(body))
if origin != "" {
req.Header.Set("Origin", origin)
}
if credential != "" {
req.Header.Set("X-API-Token", credential)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
assertOrigin := func(origin, want string) {
t.Helper()
rec := request(http.MethodGet, "/api/system/settings", origin, "", token)
if rec.Code != http.StatusOK || rec.Header().Get("Access-Control-Allow-Origin") != want {
t.Fatal("effective CORS response does not match saved policy")
}
if rec.Header().Get("Access-Control-Allow-Credentials") != "" && want == "" {
t.Fatal("removed trust still enables credentialed CORS")
}
if want != "" && want != "*" && (rec.Header().Get("Access-Control-Allow-Credentials") != "true" || !strings.Contains(rec.Header().Get("Vary"), "Origin")) {
t.Fatal("exact-origin credential/Vary contract changed")
}
if want == "*" && rec.Header().Get("Access-Control-Allow-Credentials") != "" {
t.Fatal("wildcard gained credentialed browser access")
}
}
origin := "https://old.example"
assertOrigin(origin, original)
rec := request(http.MethodPost, "/api/system/settings/update", "", `{"allowedOrigins":""}`, token)
if rec.Code != http.StatusOK {
t.Fatalf("clear: status %d", rec.Code)
}
for _, candidate := range []string{origin, "https://untrusted.example", ""} {
assertOrigin(candidate, "")
}
preflight := request(http.MethodOptions, "/api/system/settings", origin, "", "")
if preflight.Code != http.StatusOK || preflight.Header().Get("Access-Control-Allow-Origin") != "" {
t.Fatal("cleared policy still grants preflight access")
}
get := request(http.MethodGet, "/api/system/settings", "", "", token)
var visible SystemSettingsResponse
if err := json.Unmarshal(get.Body.Bytes(), &visible); err != nil || visible.AllowedOrigins != "" {
t.Fatal("GET does not expose the effective clear")
}
saved, err := persistence.LoadSystemSettings()
if err != nil || saved == nil || saved.AllowedOrigins != "" || saved.AllowedEmbedOrigins != initial.AllowedEmbedOrigins || saved.AllowEmbedding {
t.Fatal("clear lost saved policy or widened embedding")
}
if cfg.TLSCertFile != "synthetic-cert" || cfg.TLSKeyFile != "synthetic-key" {
t.Fatal("CORS edit altered TLS configuration")
}
restarted, err := config.LoadWithoutLoggingInit()
if err != nil || restarted.AllowedOrigins != "" {
t.Fatal("production config reload revived removed trust")
}
if rec := request(http.MethodGet, "/api/system/settings", origin, "", ""); rec.Code != http.StatusUnauthorized {
t.Fatal("same-origin policy bypassed authentication")
}
// CORS grants never replace the session mutation's CSRF requirement.
session := generateSessionToken()
GetSessionStore().CreateSession(session, time.Hour, "cors-fixture", "192.0.2.1", "admin")
t.Cleanup(func() { GetSessionStore().DeleteSession(session) })
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{"allowedOrigins":"*"}`))
req.Header.Set("User-Agent", "cors-fixture")
req.AddCookie(&http.Cookie{Name: sessionCookieName(false), Value: session})
csrf := httptest.NewRecorder()
router.ServeHTTP(csrf, req)
if csrf.Code != http.StatusForbidden || cfg.AllowedOrigins != "" {
t.Fatal("CORS clear weakened CSRF")
}
const readToken = "cors-read-synthetic.12345678"
cfg.APITokens = append(cfg.APITokens, config.APITokenRecord{ID: "read-only", Hash: internalauth.HashAPIToken(readToken), Scopes: []string{config.ScopeSettingsRead}})
if rec := request(http.MethodPost, "/api/system/settings/update", "", `{"allowedOrigins":"*"}`, readToken); rec.Code != http.StatusForbidden || cfg.AllowedOrigins != "" {
t.Fatal("settings:read token widened trust")
}
// An explicit new exact list works; prefixes and paths do not match.
rec = request(http.MethodPost, "/api/system/settings/update", "", `{"allowedOrigins":"https://trusted.example, https://other.example"}`, token)
if rec.Code != http.StatusOK {
t.Fatalf("set exact list: %d", rec.Code)
}
assertOrigin("https://trusted.example", "https://trusted.example")
assertOrigin("https://other.example", "https://other.example")
assertOrigin("https://trusted.example.evil", "")
assertOrigin("https://trusted.example/", "")
assertOrigin("", "")
})
}
}
func TestAllowedOriginsSaveFailurePreservesPolicy(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{DataPath: dir, ConfigPath: dir, AllowedOrigins: "https://trusted.example"}
h, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
initial.AllowedOrigins = cfg.AllowedOrigins
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
before, err := os.ReadFile(filepath.Join(dir, "system.json"))
if err != nil {
t.Fatal(err)
}
persistence.SetFileSystem(failingWriteFileSystem{})
reloads := 0
h.reloadSystemSettingsFunc = func() { reloads++ }
req := httptest.NewRequest(http.MethodPost, "/api/system/settings/update", strings.NewReader(`{"allowedOrigins":""}`))
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
h.HandleUpdateSystemSettings(rec, req)
after, err := os.ReadFile(filepath.Join(dir, "system.json"))
if err != nil {
t.Fatal(err)
}
if rec.Code != http.StatusInternalServerError || cfg.AllowedOrigins != initial.AllowedOrigins || reloads != 0 || !bytes.Equal(before, after) {
t.Fatal("failed save changed durable/effective browser trust")
}
}
func TestAllowedOriginsGetUsesDeploymentPolicy(t *testing.T) {
dir := t.TempDir()
cfg := &config.Config{DataPath: dir, ConfigPath: dir, AllowedOrigins: "https://deploy.example", EnvOverrides: map[string]bool{"allowedOrigins": true}}
h, persistence, token := setupTelemetryTest(t, cfg)
initial := config.DefaultSystemSettings()
initial.AllowedOrigins = "https://saved.example"
if err := persistence.SaveSystemSettings(*initial); err != nil {
t.Fatal(err)
}
req := httptest.NewRequest(http.MethodGet, "/api/system/settings", nil)
req.Header.Set("X-API-Token", token)
rec := httptest.NewRecorder()
h.HandleGetSystemSettings(rec, req)
var response SystemSettingsResponse
if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil || rec.Code != http.StatusOK || response.AllowedOrigins != cfg.AllowedOrigins || !response.EnvOverrides["allowedOrigins"] {
t.Fatal("GET misrepresented deployment-owned browser trust")
}
}