Found during the review of #114, which masked the credential in the source detail page. These three sibling paths leak the same secret and were outside that PR's scope. Milestoned 1.0.0 for the same reason #113 was: the milestone is internet-facing readiness.
The first one is the most serious, because it writes the credential to disk.
Delivery errors persist the full URL.internal/delivery/target_slack.go:128,142 and internal/delivery/target_http.go:337 wrap the *url.Error from the HTTP client into DeliveryResult.Error. Go embeds the request URL in that error, so any transport failure — DNS, TLS, timeout, SSRF dial block — stores Post "https://hooks.slack.com/services/T../B../XXXX": dial tcp ... into the per-webhook SQLite database. It is not rendered today, but the field is tagged json:"error,omitempty", so the REST API on the roadmap serializes it the moment it exists.
SSRF rejection logs the submitted URL.internal/handlers/source_management.go:1019 — h.log.Warn("target URL blocked by SSRF protection", "url", targetURL, ...) writes the full URL to the application log.
source_logs.html still receives raw config.internal/handlers/source_management.go:757 attaches raw database.Target values, config included, to that page's template data. Nothing renders it today, so it is one {{.Target.Config}} away from reintroducing the exact bug #113 just fixed — the view projection does not cover this page.
Definition of done
Delivery targets convert *url.Error into a message carrying only maskURL(...) before it reaches DeliveryResult.Error.
source_management.go:1019 logs the masked URL.
loadTargetMap hands source_logs.html a TargetView rather than a raw database.Target, so the raw config is unreachable from that template too.
A test asserting a failed Slack delivery's stored DeliveryResult.Error contains no path segment of the configured webhook URL. This is the load-bearing one.
Implementation requirements
Reuse the existing maskURL in internal/delivery/target_config_view.go; do not write a second masker.
Branch from next, PR based on next, single commit, title ending (closes #N).
Gate on make check plus the Docker lint path with the cache defeated — a fully cached script/cibuild is not evidence.
Found during the review of https://git.eeqj.de/sneak/webhooker/pulls/114, which masked the credential in the source detail page. These three sibling paths leak the same secret and were outside that PR's scope. Milestoned 1.0.0 for the same reason https://git.eeqj.de/sneak/webhooker/issues/113 was: the milestone is internet-facing readiness.
The first one is the most serious, because it writes the credential to disk.
1. **Delivery errors persist the full URL.** `internal/delivery/target_slack.go:128,142` and `internal/delivery/target_http.go:337` wrap the `*url.Error` from the HTTP client into `DeliveryResult.Error`. Go embeds the request URL in that error, so any transport failure — DNS, TLS, timeout, SSRF dial block — stores `Post "https://hooks.slack.com/services/T../B../XXXX": dial tcp ...` into the per-webhook SQLite database. It is not rendered today, but the field is tagged `json:"error,omitempty"`, so the REST API on the roadmap serializes it the moment it exists.
2. **SSRF rejection logs the submitted URL.** `internal/handlers/source_management.go:1019` — `h.log.Warn("target URL blocked by SSRF protection", "url", targetURL, ...)` writes the full URL to the application log.
3. **`source_logs.html` still receives raw config.** `internal/handlers/source_management.go:757` attaches raw `database.Target` values, config included, to that page's template data. Nothing renders it today, so it is one `{{.Target.Config}}` away from reintroducing the exact bug https://git.eeqj.de/sneak/webhooker/issues/113 just fixed — the view projection does not cover this page.
## Definition of done
- Delivery targets convert `*url.Error` into a message carrying only `maskURL(...)` before it reaches `DeliveryResult.Error`.
- `source_management.go:1019` logs the masked URL.
- `loadTargetMap` hands `source_logs.html` a `TargetView` rather than a raw `database.Target`, so the raw config is unreachable from that template too.
- A test asserting a failed Slack delivery's stored `DeliveryResult.Error` contains no path segment of the configured webhook URL. This is the load-bearing one.
## Implementation requirements
- Reuse the existing `maskURL` in `internal/delivery/target_config_view.go`; do not write a second masker.
- Branch from `next`, PR based on `next`, single commit, title ending ` (closes #N)`.
- Gate on `make check` plus the Docker lint path with the cache defeated — a fully cached `script/cibuild` is not evidence.
clawbot
added this to the 1.0.0 milestone 2026-08-11 14:37:28 +02:00
Move maskURL into internal/delivery/url_mask.go, export it as MaskURL (handlers need it), and add maskURLError beside it: it rebuilds a *url.Error with URL replaced by MaskURL(...) and Op/Err preserved, so the operation and the underlying transport error (DNS, TLS, refused, timeout) survive and only path/query/userinfo are dropped. One masker, one set of semantics.
Apply maskURLError at the two points where a transport or request-construction error is born: executeHTTPRequest (shared by the Slack and HTTP targets) and the http.NewRequestWithContext error paths. Masking at birth means every wrapping site downstream is safe by construction. Also applied to ValidateTargetURL's url.Parse branch, which embeds the raw URL in the error the SSRF log line prints.
loadTargetMap returns map[string]delivery.TargetView, and EventWithDeliveries.Deliveries becomes a []DeliveryView (status plus TargetView), so source_logs.html has no path to a stored config blob at all.
Tests: load-bearing assertion that a failed Slack delivery's stored DeliveryResult.Error contains no path segment of the webhook URL while still naming the failure class, plus a rendered-page test for the logs template.
Plan:
1. Move `maskURL` into `internal/delivery/url_mask.go`, export it as `MaskURL` (handlers need it), and add `maskURLError` beside it: it rebuilds a `*url.Error` with `URL` replaced by `MaskURL(...)` and `Op`/`Err` preserved, so the operation and the underlying transport error (DNS, TLS, refused, timeout) survive and only path/query/userinfo are dropped. One masker, one set of semantics.
2. Apply `maskURLError` at the two points where a transport or request-construction error is born: `executeHTTPRequest` (shared by the Slack and HTTP targets) and the `http.NewRequestWithContext` error paths. Masking at birth means every wrapping site downstream is safe by construction. Also applied to `ValidateTargetURL`'s `url.Parse` branch, which embeds the raw URL in the error the SSRF log line prints.
3. `loadTargetMap` returns `map[string]delivery.TargetView`, and `EventWithDeliveries.Deliveries` becomes a `[]DeliveryView` (status plus `TargetView`), so `source_logs.html` has no path to a stored config blob at all.
4. Tests: load-bearing assertion that a failed Slack delivery's stored `DeliveryResult.Error` contains no path segment of the webhook URL while still naming the failure class, plus a rendered-page test for the logs template.
Implemented in #121 (issue-118-mask-credential-leaks, commit b6529f4).
All three leaks are closed with the one masker: maskURL moved to internal/delivery/url_mask.go, exported as MaskURL, joined by maskURLError, which rebuilds a *url.Error with the URL masked and Op plus the wrapped cause preserved. Masking happens where the errors are raised — executeHTTPRequest (shared by both targets), the request-construction paths, and ValidateTargetURL's url.Parse branch, whose error is logged and shown — so the stored error keeps its diagnostic value:
loadTargetMap returns map[string]delivery.TargetView and EventWithDeliveries.Deliveries is now a []DeliveryView, so source_logs.html has no reachable path to a stored config blob.
Verified: the load-bearing test asserts the stored DeliveryResult.Error holds no path segment of the webhook URL while still naming the operation, the masked host and the transport failure. Removing the mask from executeHTTPRequest makes it fail on all five segments and on the missing masked host; restoring it makes it pass. make check green, and the Docker path with the cache defeated ran make lint (0 issues) and make test (all packages with real durations, none cached) after rebasing onto current next.
Implemented in https://git.eeqj.de/sneak/webhooker/pulls/121 (`issue-118-mask-credential-leaks`, commit `b6529f4`).
All three leaks are closed with the one masker: `maskURL` moved to `internal/delivery/url_mask.go`, exported as `MaskURL`, joined by `maskURLError`, which rebuilds a `*url.Error` with the URL masked and `Op` plus the wrapped cause preserved. Masking happens where the errors are raised — `executeHTTPRequest` (shared by both targets), the request-construction paths, and `ValidateTargetURL`'s `url.Parse` branch, whose error is logged and shown — so the stored error keeps its diagnostic value:
before: sending request: Post "http://host:45897/services/T00000000/B00000000/XXXXXXXXXXXXXXXXXXXXXXXX": dial tcp 127.0.0.1:45897: connect: connection refused
after: sending request: Post "http://host:45897/...": dial tcp 127.0.0.1:45897: connect: connection refused
`loadTargetMap` returns `map[string]delivery.TargetView` and `EventWithDeliveries.Deliveries` is now a `[]DeliveryView`, so `source_logs.html` has no reachable path to a stored config blob.
Verified: the load-bearing test asserts the stored `DeliveryResult.Error` holds no path segment of the webhook URL while still naming the operation, the masked host and the transport failure. Removing the mask from `executeHTTPRequest` makes it fail on all five segments and on the missing masked host; restoring it makes it pass. `make check` green, and the Docker path with the cache defeated ran `make lint` (0 issues) and `make test` (all packages with real durations, none cached) after rebasing onto current `next`.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Found during the review of #114, which masked the credential in the source detail page. These three sibling paths leak the same secret and were outside that PR's scope. Milestoned 1.0.0 for the same reason #113 was: the milestone is internet-facing readiness.
The first one is the most serious, because it writes the credential to disk.
internal/delivery/target_slack.go:128,142andinternal/delivery/target_http.go:337wrap the*url.Errorfrom the HTTP client intoDeliveryResult.Error. Go embeds the request URL in that error, so any transport failure — DNS, TLS, timeout, SSRF dial block — storesPost "https://hooks.slack.com/services/T../B../XXXX": dial tcp ...into the per-webhook SQLite database. It is not rendered today, but the field is taggedjson:"error,omitempty", so the REST API on the roadmap serializes it the moment it exists.internal/handlers/source_management.go:1019—h.log.Warn("target URL blocked by SSRF protection", "url", targetURL, ...)writes the full URL to the application log.source_logs.htmlstill receives raw config.internal/handlers/source_management.go:757attaches rawdatabase.Targetvalues, config included, to that page's template data. Nothing renders it today, so it is one{{.Target.Config}}away from reintroducing the exact bug #113 just fixed — the view projection does not cover this page.Definition of done
*url.Errorinto a message carrying onlymaskURL(...)before it reachesDeliveryResult.Error.source_management.go:1019logs the masked URL.loadTargetMaphandssource_logs.htmlaTargetViewrather than a rawdatabase.Target, so the raw config is unreachable from that template too.DeliveryResult.Errorcontains no path segment of the configured webhook URL. This is the load-bearing one.Implementation requirements
maskURLininternal/delivery/target_config_view.go; do not write a second masker.next, PR based onnext, single commit, title ending(closes #N).make checkplus the Docker lint path with the cache defeated — a fully cachedscript/cibuildis not evidence.Plan:
maskURLintointernal/delivery/url_mask.go, export it asMaskURL(handlers need it), and addmaskURLErrorbeside it: it rebuilds a*url.ErrorwithURLreplaced byMaskURL(...)andOp/Errpreserved, so the operation and the underlying transport error (DNS, TLS, refused, timeout) survive and only path/query/userinfo are dropped. One masker, one set of semantics.maskURLErrorat the two points where a transport or request-construction error is born:executeHTTPRequest(shared by the Slack and HTTP targets) and thehttp.NewRequestWithContexterror paths. Masking at birth means every wrapping site downstream is safe by construction. Also applied toValidateTargetURL'surl.Parsebranch, which embeds the raw URL in the error the SSRF log line prints.loadTargetMapreturnsmap[string]delivery.TargetView, andEventWithDeliveries.Deliveriesbecomes a[]DeliveryView(status plusTargetView), sosource_logs.htmlhas no path to a stored config blob at all.DeliveryResult.Errorcontains no path segment of the webhook URL while still naming the failure class, plus a rendered-page test for the logs template.clawbot referenced this issue2026-08-11 14:48:08 +02:00
Implemented in #121 (
issue-118-mask-credential-leaks, commitb6529f4).All three leaks are closed with the one masker:
maskURLmoved tointernal/delivery/url_mask.go, exported asMaskURL, joined bymaskURLError, which rebuilds a*url.Errorwith the URL masked andOpplus the wrapped cause preserved. Masking happens where the errors are raised —executeHTTPRequest(shared by both targets), the request-construction paths, andValidateTargetURL'surl.Parsebranch, whose error is logged and shown — so the stored error keeps its diagnostic value:loadTargetMapreturnsmap[string]delivery.TargetViewandEventWithDeliveries.Deliveriesis now a[]DeliveryView, sosource_logs.htmlhas no reachable path to a stored config blob.Verified: the load-bearing test asserts the stored
DeliveryResult.Errorholds no path segment of the webhook URL while still naming the operation, the masked host and the transport failure. Removing the mask fromexecuteHTTPRequestmakes it fail on all five segments and on the missing masked host; restoring it makes it pass.make checkgreen, and the Docker path with the cache defeated ranmake lint(0 issues) andmake test(all packages with real durations, none cached) after rebasing onto currentnext.