Mask the http target's destination URL in the UI (closes #115) #126
Reference in New Issue
Block a user
Delete Branch "issue-115-mask-http-destination-url"
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?
Speculative, ahead of your decision on
#115. Merging it decides
option 1; closing it decides against. Title keeps the
WIP:prefix soit cannot land by accident.
Rebased onto current
next(2ee720a) and re-verified against thattree; head is
855439c, still a single commit.Option chosen
Option 1 — mask unconditionally, reusing the existing
MaskURLfrominternal/delivery/url_mask.go, which rendersscheme://host/.... It isthe rule already applied to the slack target, so both target types now
behave identically, and it needs no new masker and no heuristic.
Rejected:
or high-entropy path segments). A heuristic that can be wrong in both
directions: it leaks a self-hosted Mattermost or a bespoke endpoint it
does not recognise, and it hides a plain
/hookpath an operatorwanted to read.
authenticated. It keeps a written-down credential on screen, so it
survives into screenshots, screen shares and browser-rendered PDFs
regardless of who is logged in.
Still non-redundant against current
nextnexthas since gained target config masking(#113), credential masking in
delivery errors and logs
(#118) and buffered template
rendering (#123). None of them
covers this field: on
2ee720a,httpConfigFieldsstill rendersValue: cfg.URLraw.MaskURLexists unchanged ininternal/delivery/url_mask.goand still elides path, query anduserinfo, returning the neutral placeholder for a URL without scheme and
host.
Correction to the issue's usability mitigation
The issue argues the cost is acceptable because "the full value stays
available in the edit form". That is not true in the code today.
internal/server/routes.goregistersPOST /source/{sourceID}/targets,.../targets/{targetID}/toggleand.../targets/{targetID}/delete—there is no target edit route, and
templates/source_edit.htmleditsonly the webhook's name, description and retention. A target's
destination URL is write-once: after this change it is not recoverable
anywhere in the UI, only from the database. Still true on
2ee720a—routes.goandtemplates/are byte-identical to the tree this wasfirst written against.
So the real cost of option 1 is higher than the issue states. It still
looks like the right call — an unrecoverable credential is the normal
outcome for a secret, and the operator holds the value at the source
(Slack, Discord, Teams) — but the decision should be made knowing that.
#127 is the target edit form
that repays the usability debt; it is a gap in your existing design
rather than a defect this PR introduces, so it is filed as the work that
makes option 1 cheap again, not as a bug.
What changed
internal/delivery/target_config_view.go—httpConfigFieldsrendersMaskURL(cfg.URL)for theDestination URLfield. No new masker; noother field changed.
Sibling-path audit, re-walked against
2ee720aEvery path where target data reaches an operator, re-checked in the
current tree. No verdict changed.
GET /source/{id}(templates/source_detail.html).Name,.Type,.Active, then each.Configlabel/value pairGET /source/{id}/logs(templates/source_logs.html).Target.Nameand.Statusonly — no delivery error, no configGET /sources(templates/sources_list.html).TargetCountonlyGET /source/{id}/edit(templates/source_edit.html)target,configorurlreference at allDeliveryResult.Errorand loggedmaskURLError(target_http.go:367,target_http.go:507,target_slack.go:128)POST /source/{id}/targetsSSRF rejection (response body + log line)ValidateTargetURL's error, which reports scheme, hostname or IP only; itsurl.Parsebranch goes throughmaskURLError(ssrf.go:99), and the log line throughdelivery.MaskURL(source_management.go:1142)/metricsgo-http-metricslabels only: route pattern, method, code, service. No URL-valued label.No new path to add.
internal/server/routes.goand every file undertemplates/are unchanged between the tree this was first auditedagainst and
2ee720a. The twelve commits that landed onnextinbetween touch config parsing, session codec, receiver rate limiting,
lifecycle shutdown, buffered rendering and the sweepers; the only new
log call sites they add are in the archive sweeper and the webhook
receiver, neither of which carries target configuration.
TargetViewstill has no raw config field, so no template can reach the stored blob.
Mutation evidence, re-run on the rebased head
With
MaskURL(cfg.URL)reverted tocfg.URLand nothing else changed,make testexits 2:The done-criterion test fails on the secret path segments themselves —
source_detail_test.go:164through:167report the rendered pagecontaining
/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX,T00000000,B00000000andXXXXXXXXXXXXXXXXXXXXXXXX, and:172reports it no longer containing
https://hooks.slack.com/.... The viewtest fails the same way at
target_config_view_test.go:213and:220-:223.TestHandleSourceDetail_MasksSlackWebhookURLkeepspassing throughout, so the mutation is scoped to the http path. Reverted
after; the tree is identical to the pushed commit.
New tests:
TestHandleSourceDetail_MasksHTTPDestinationURL— the done-criterion.Seeds an
httptarget whose destination is a secret-bearing incomingwebhook URL, runs the real handler, asserts the rendered page contains
none of the path segments and does show
https://hooks.slack.com/....TestNewTargetViews_HTTPMasksDestinationURL— same rule at the viewlayer.
Gates, on
855439cmake checkexits 0 (test, lint, fmt-check).Containerized, cache defeated on both check-running stages:
docker build --no-cache-filter=lint --no-cache-filter=builder .exits0 in 2m48s. Not a cached no-op:
lintstage:make fmt-checkin 0.9s, thenmake lintfor 47.6sreporting
0 issues.builderstage:make testfor 51.5s, every package with a realduration and zero
(cached)markers in the whole log —config 1.124s,database 2.053s,delivery 3.739s,globals 1.016s,handlers 2.819s,lifecycle 1.123s,logger 1.023s,middleware 1.102s,server 1.611s,session 1.055s. Both new tests log--- PASSin that run.CACHED, all in the final runtime stage.--no-cache-filterrather than a prune, per the shared-hostrule. No containers were started; the tagged image was removed after.
Disclosure on host lint: the first host
make checkfailed with 8 lintissues reported against files under
/tmp/rev-130-clawbot-merge/—another session's checkout, not present in this one. Re-running with an
isolated
GOLANGCI_LINT_CACHEgives0 issues.andmake checkexit 0.That is first-hand reproduction of the shared-cache corruption behind
#106 and
#109, and it is why the
containerized run above is the evidence that counts.
WIP: Mask the http target's destination URL in the UI (closes #115)to Mask the http target's destination URL in the UI (closes #115)5c0ea2b44fto855439cc56Mask the http target's destination URL in the UI (closes #115)to WIP: Mask the http target's destination URL in the UI (closes #115)WIP: Mask the http target's destination URL in the UI (closes #115)to Mask the http target's destination URL in the UI (closes #115)PASS — satisfies the done-criterion of #115; single commit
855439connext, clean fast-forward, no attribution trailers, no scope creep.Gate, in an independent clone at the PR head:
docker build --no-cache-filter=lint --no-cache-filter=builder .exit 0. Executed, not replayed:#17 [lint 7/8] RUN make fmt-check0.3s;#18 make lint63.1s emitting0 issues.;#30 make test62.2s with real per-package durations (delivery 4.460s,handlers 3.901s, …) and zero(cached)markers. The 15CACHEDlayers are base images,go mod downloadand the runtime stage only — none on a check-running layer. Both new tests logged--- PASSin that run. Image removed; no containers started.check / check (push)green on855439c.Probes that were the likely failure sites:
MaskURL(cfg.URL)tocfg.URLand changing nothing else makesmake testexit 2 with 5 failures. The done-criterion test fails on the absence assertions, not merely the masked-form one:source_detail_test.go:164–:167(NotContainsof the secret path and each of its segments) andtarget_config_view_test.go:220–:223. Tree restored;git statusclean at855439c.MaskURLcoverage. Exercised in a throwaway copy against userinfo, query-only, fragment, path+query+fragment, explicit port, IPv6 literal, no-path, trailing-slash-only,ftp/mailto/javascript/data/unknown scheme, relative URL, embedded space/tab/NUL, and%zz. Every credential-bearing component is dropped; anything without both scheme and host yields(unavailable), never the raw string. No leak found.2ee720a:httpConfigFieldsrenderedValue: cfg.URLraw before this commit.source_detail.html,source_logs.html,sources_list.html,source_edit.html, the add-target form (novalue=prefill), the target create/toggle/delete redirects, everyslogsite ininternal/delivery,internal/handlers,internal/server,DeliveryResult.Error(masked at all raise sites),ValidateTargetURL's rejection body, and thego-http-metricslabel set. Clean, with one exception below.Out of scope, pre-existing, non-blocking — but it narrows the PR body's "no second leak" claim:
internal/handlers/source_management.go:1002reads the destination withr.FormValue("url"), which falls back to the query string when the body has no such field, whileinternal/middleware/middleware.go:123logs"url", r.URL.String()unmasked.POST /source/{id}/targets?url=https://hooks.slack.com/services/T/B/SECRETtherefore creates the target and writes the full credential to the access log (and into Sentry request context whenSENTRY_DSNis set). Neither line is touched by this PR and the browser form posts in the body, so this is not a defect introduced here — but it is a live path where the destination URL still escapes unmasked, and it deserves its own issue rather than being covered by the audit table's "clean" verdicts.r.PostFormValueat:1001–:1005closes it.Anomalies that pass anyway:
MaskURLemits no/...marker for a query-only URL (https://h?token=xrenders ashttps://h), because the elision is keyed on a non-empty path. The query is still dropped, so no leak — cosmetic only.TODO.md:41will be stale on merge (it lists #115 as an open owner decision).TODO.mdis updated in batches on this branch, not per-PR, so this is convention rather than an omission.Disclosure: per the reviewer's instruction the PR body's opening paragraph (still describing the change as speculative and
WIP:-prefixed) was treated as known-stale and excluded from judgement.