Read form fields from the POST body only (closes #160)
All checks were successful
check / check (push) Successful in 2m54s

internal/handlers/source_management.go read the target destination
with r.FormValue, which falls back to the URL query string when the
field is absent from the body. So

    POST /source/{id}/targets?url=https://hooks.slack.com/services/T/B/S

created a working target from a value carried on the request line,
where logs, proxies, Referer headers and error trackers record it.
That is the remaining ingress path of the credential-exposure class
the render, delivery-error and log-line paths were each closed for.

Every form read in these handlers is now r.PostFormValue, so no
query-string value can populate stored configuration or be taken as a
credential. The one deliberate query read, `page` on the authenticated
pagination links, is untouched: it uses r.URL.Query().Get already.

The access log no longer carries the query on any branch, so the log
half of the report is already mitigated; the Sentry half is not. The
SDK attaches the request to every captured event and copies
r.URL.RawQuery into Request.QueryString independently of the access
log, so a BeforeSend hook clears that field before an event leaves the
process. Scheme, host, path and method stay, which is what names the
failing route.

Second barrier, for the JSON path that does not exist yet: the fields
that hold a credential are tagged json:"-" so the first handler to
marshal a model cannot serialise one. Target.Config holds the
incoming-webhook URL, APIKey.Key is a bearer token, and Setting.Value
holds the session encryption key. delivery.TargetView remains the
masking barrier for the HTML path, which is unaffected.
This commit is contained in:
2026-08-17 22:49:25 +00:00
parent 41ff16a817
commit 3925fce24a
13 changed files with 529 additions and 23 deletions

View File

@@ -4,6 +4,7 @@ import (
"log/slog"
"net/http"
"github.com/getsentry/sentry-go"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware"
@@ -13,6 +14,16 @@ import (
// build requests that sit exactly at, below, and above it.
const MaxFormBodySizeForTest = maxFormBodySize
// ScrubSentryRequestForTest exposes the BeforeSend hook that
// enableSentry installs, so a test can assert on what it leaves in an
// event without standing up a Sentry client.
func ScrubSentryRequestForTest(
event *sentry.Event,
hint *sentry.EventHint,
) *sentry.Event {
return scrubSentryRequest(event, hint)
}
// NewRouterForTest builds the real route tree via SetupRoutes with
// the supplied middleware and handlers, bypassing the fx lifecycle
// and the HTTP listener. Tests use it so that route-group middleware

40
internal/server/sentry.go Normal file
View File

@@ -0,0 +1,40 @@
package server
import "github.com/getsentry/sentry-go"
// sentryRedactedQuery stands in for the query string on every event
// shipped to Sentry.
const sentryRedactedQuery = "(redacted)"
// scrubSentryRequest drops the query string from an event's request
// context before it leaves the process.
//
// sentryhttp attaches the whole *http.Request to the scope, and
// sentry.NewRequest copies r.URL.RawQuery verbatim into
// Request.QueryString. That path is independent of the access log: it
// is populated from the request even though the log line for the same
// request records only the route pattern or a redacted query. Any
// error or panic captured while serving a request would therefore ship
// the query string to a third-party service, and a query string is
// client-chosen text that a mistyped or hand-built request can put a
// credential into.
//
// The query is not debugging signal here. One route in the service
// reads a query parameter at all — `page`, on the authenticated
// pagination links in internal/handlers/source_management.go — and
// Request.URL still carries scheme, host and path, which is what
// identifies the failing route.
func scrubSentryRequest(
event *sentry.Event,
_ *sentry.EventHint,
) *sentry.Event {
if event == nil || event.Request == nil {
return event
}
if event.Request.QueryString != "" {
event.Request.QueryString = sentryRedactedQuery
}
return event
}

View File

@@ -0,0 +1,105 @@
package server_test
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/getsentry/sentry-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/server"
)
// sentrySecretSegment is the path segment of a Slack incoming-webhook
// URL — the part that is the bearer credential. No Sentry event may
// carry it.
const sentrySecretSegment = "T00000000/B00000000/QQSENTRYSECRETQQ"
// sentryEventFor builds the event Sentry would ship for a request
// carrying the given raw query, using the SDK's own request
// conversion rather than a hand-built Request, so the test tracks
// what the SDK actually collects.
func sentryEventFor(t *testing.T, rawQuery string) *sentry.Event {
t.Helper()
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
"/source/abc/targets?"+rawQuery,
nil,
)
event := sentry.NewEvent()
event.Request = sentry.NewRequest(req)
return event
}
// TestSentryScrub_QueryStringIsCollectedUnscrubbed pins the reason the
// hook exists: the SDK copies the raw query into the event on its own,
// independently of the access log, which records only the route
// pattern or a redacted query for the same request.
func TestSentryScrub_QueryStringIsCollectedUnscrubbed(t *testing.T) {
t.Parallel()
event := sentryEventFor(
t, "url=https://hooks.slack.com/services/"+sentrySecretSegment,
)
require.Contains(
t, event.Request.QueryString, sentrySecretSegment,
"the SDK is expected to collect the raw query; "+
"if it no longer does, the scrub hook's premise changed",
)
}
// TestSentryScrub_RedactsQueryString is the regression test: the hook
// installed on both BeforeSend and BeforeSendTransaction must leave no
// byte of the query in the event that goes off-host.
func TestSentryScrub_RedactsQueryString(t *testing.T) {
t.Parallel()
event := sentryEventFor(
t, "url=https://hooks.slack.com/services/"+sentrySecretSegment,
)
scrubbed := server.ScrubSentryRequestForTest(event, nil)
require.NotNil(t, scrubbed)
encoded, err := json.Marshal(scrubbed)
require.NoError(t, err)
assert.NotContains(t, string(encoded), sentrySecretSegment)
assert.NotContains(t, string(encoded), "hooks.slack.com")
}
// TestSentryScrub_KeepsTheRoutingContext checks the hook does not cost
// the debugging signal: the path still identifies the failing route.
func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) {
t.Parallel()
event := sentryEventFor(t, "page=2")
scrubbed := server.ScrubSentryRequestForTest(event, nil)
require.NotNil(t, scrubbed)
assert.Contains(t, scrubbed.Request.URL, "/source/abc/targets")
assert.Equal(t, http.MethodPost, scrubbed.Request.Method)
}
// TestSentryScrub_ToleratesEventsWithoutARequest covers the events the
// hook sees outside an HTTP handler, where no request is attached.
func TestSentryScrub_ToleratesEventsWithoutARequest(t *testing.T) {
t.Parallel()
event := sentry.NewEvent()
scrubbed := server.ScrubSentryRequestForTest(event, nil)
require.NotNil(t, scrubbed)
assert.Nil(t, scrubbed.Request)
assert.Nil(t, server.ScrubSentryRequestForTest(nil, nil))
}

View File

@@ -148,6 +148,10 @@ func (s *Server) enableSentry() {
s.params.Globals.Appname,
s.params.Globals.Version,
),
// Both hooks, because the SDK runs one for error events
// and the other for transactions.
BeforeSend: scrubSentryRequest,
BeforeSendTransaction: scrubSentryRequest,
})
if err != nil {
s.log.Error("sentry init failure", "error", err)