Mask target config on the source detail page (closes #113) #114
Reference in New Issue
Block a user
Delete Branch "issue-113-mask-target-config"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #113
templates/source_detail.htmlrendered each target's stored config blob verbatim. For aslacktarget that blob holds the incoming webhook URL, which is a bearer credential — anyone holding it can post to the channel indefinitely, and it cannot be scoped or revoked per holder. Rendering it put the credential into browser history, screenshots and any support screen share.What changed
internal/delivery/target_config_view.go.NewTargetViewsprojects[]database.TargetintoTargetView, which has no raw config field at all — the blob is not reachable from any template, present or future. The template rangesConfigField{Label, Value}pairs instead.(*SlackTargetConfig).MaskedWebhookURL(), backed bymaskURL, so it is unit-testable and cannot be bypassed from a template. It reduces the URL to scheme + host and elides the path, query and any userinfo.slack→ masked webhook URL;http→ destination URL, timeout, header count, max retries, max queue size;database→ archive expiry;log→ none.Configuration: (unavailable).Two deliberate calls
https://hooks.slack.com/services/...; this rendershttps://hooks.slack.com/.... The field accepts an arbitrary URL, so no path segment can be assumed non-secret — keeping the first one would be safe for Slack's fixed/services/prefix and unsafe for anything else. Still scheme + host + an elided path, per the definition of done.Authorization: Bearer ...; rendering them would reintroduce the same class of leak the issue is about.Verification
internal/handlers/source_detail_test.go—TestHandleSourceDetail_MasksSlackWebhookURLrenders the real source detail page throughHandleSourceDetailfor aslacktarget and asserts the body contains none of the webhook URL's secret path segments (nor thewebhookUrlkey), and does contain the masked value. This is the load-bearing regression test.TestHandleSourceDetail_RendersNamedTargetFieldscovershttp,databaseand an unknown type end-to-end, asserting the header value never reaches the page and the unknown type gets the placeholder.internal/delivery/target_config_view_test.go— unit tests for the masking (query, userinfo, no path, unparseable, empty) and the per-type field sets, including every unpresentable-config path.make check: green,0 issues.from the linter.docker build --no-cache-filter=lint,builder --progress=plain .(scoped invalidation, no prune) — the lint and test layers executed rather than replaying cache:script/cibuildthen exits 0.Scope
Rendering only — the stored config format and the delivery path are untouched. No new Tailwind classes (
text-xs,text-gray-500,text-gray-700,break-all,mt-1,font-mediumare all already instatic/css/tailwind.css), so no CSS regeneration.TODO.mduntouched.One lint suppression, disclosed:
//nolint:gosec // G101on the test case whose URL carries fabricateduser:pw@userinfo — that case exists precisely to prove userinfo is stripped by the mask.Noted while working, not addressed here: the linter warns that
gomodguardis deprecated in favour ofgomodguard_v2. It is a repo-wide config change, out of scope for this issue.PASS — masking, the no-raw-field projection, the fail-closed paths, tests, single commit on
next,make fmt/lint/test all verified; cache-defeateddocker build --no-cache-filter=lint,builderexecutedmake fmt-check(1.7s),make lint(88.6s,0 issues.) andmake test(85.4s, 0(cached)markers), exit 0.Disclosures:
MaskedWebhookURLto return the raw URL in my own throwaway clone to prove the regression test is load-bearing:source_detail_test.go:120-123,129failed, as did 3 delivery tests. Reverted; my tree is clean at8605797. Nothing was changed or pushed here.8605797is stillpending/ "Waiting to run" (runner backlog), so I could not confirm a green check — it must be green before merge. Note the repo's recent greens report "Successful in 3-4s", i.e. an all-CACHEDbuild, so that signal is weak regardless; the cache-defeated run above is the stronger evidence.maskURLwith 32 adversarial inputs (no scheme, scheme-relative, opaquejavascript:/data:/mailto:/unix:, empty, whitespace, NUL and newline, userinfo-only, secret in query, secret in fragment,https:///, IPv6 host, explicit port, uppercase scheme, 5000-char path): 0 leaks, 0 panics, fails closed to(unavailable)on every malformed input. Userinfo and fragment are both dropped.//nolint:gosec // G101is scoped to the single"userinfo dropped"table entry.linters.default: allenablesnolintlint, whose default rejects unused directives, so the green lint above proves it is necessary.https://hooks.slack.com/...) rather than the issue's illustrative.../services/.... The normative criterion is "never the full path segments"; this is strictly more conservative.Out of scope for this PR, pre-existing, worth separate issues (not blocking):
internal/delivery/target_slack.go:128,142andinternal/delivery/target_http.go:337put the*url.Error— which embeds the full webhook URL — intoDeliveryResult.Error, persisted to the per-webhook DB (currently rendered nowhere, but taggedjson:"error,omitempty");internal/handlers/source_management.go:1019logs the full submitted URL on SSRF rejection;internal/handlers/source_management.go:757still places rawdatabase.Targetvalues (config included) into thesource_logs.htmldata map, unrendered today but one{{.Target.Config}}from a repeat of this bug.