Compare commits

1 Commits

Author SHA1 Message Date
3925fce24a 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.
2026-08-17 22:49:28 +00:00
13 changed files with 529 additions and 23 deletions

View File

@@ -1020,6 +1020,15 @@ buy the same amplification as an invented path. Nothing debuggable is
lost: `page`, on the authenticated pagination links, is the only query lost: `page`, on the authenticated pagination links, is the only query
parameter this service reads. parameter this service reads.
The query string does not leave the host by the other route either.
The Sentry SDK attaches the request to every event it captures and
copies the raw query into it, independently of the access log, so a
`BeforeSend` hook clears that field before the event is sent. The event
still carries the scheme, host, path and method, which is what names
the failing route. Nothing in this service reads a form field from the
query: every handler uses `PostFormValue`, so a value on the request
line cannot configure anything.
The remaining client-supplied fields are truncated rather than dropped, The remaining client-supplied fields are truncated rather than dropped,
each to a fixed budget: 512 bytes for `url`, `useragent` and `referer`, each to a fixed budget: 512 bytes for `url`, `useragent` and `referer`,
128 for `request_id` (chi passes an inbound `X-Request-Id` header 128 for `request_id` (chi passes an inbound `X-Request-Id` header

View File

@@ -2,12 +2,16 @@ package database
import "time" import "time"
// APIKey represents an API key for a user // APIKey represents an API key for a user.
//
// Key is a bearer credential, so it is never marshalled with the
// model. A creation handler that has to show it once returns it in its
// own response type.
type APIKey struct { type APIKey struct {
BaseModel BaseModel
UserID string `gorm:"type:uuid;not null" json:"userId"` UserID string `gorm:"type:uuid;not null" json:"userId"`
Key string `gorm:"uniqueIndex;not null" json:"key"` Key string `gorm:"uniqueIndex;not null" json:"-"`
Description string `json:"description"` Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`

View File

@@ -0,0 +1,107 @@
package database_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// keptField is a non-secret value planted alongside each secret, so
// the assertions below cannot pass by the model marshalling to nothing.
const keptField = "keepme"
// marshalModel encodes a model the way a future JSON handler would.
func marshalModel(t *testing.T, v any) string {
t.Helper()
encoded, err := json.Marshal(v)
require.NoError(t, err)
return string(encoded)
}
// TestModelsDoNotMarshalTheirSecrets pins the barrier for the JSON
// path. The /api/v1 route group exists and is empty; delivery's
// TargetView masks the credential for the HTML path only, so without
// these tags the first handler that marshals a model serialises the
// secret with it. Each field below is a live credential:
//
// - Target.Config holds an incoming-webhook URL whose path segments
// are the bearer token.
// - APIKey.Key is a bearer token outright.
// - Setting.Value holds the session encryption key.
// - User.Password holds the Argon2 hash, and was already tagged.
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
t.Parallel()
const marker = "QQMODELMARKERQQ"
cases := []struct {
name string
model any
}{
{
name: "target config",
model: database.Target{
Name: keptField,
Type: database.TargetTypeSlack,
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
},
},
{
name: "api key",
model: database.APIKey{
Description: keptField,
Key: marker,
},
},
{
name: "setting value",
model: database.Setting{
Key: keptField,
Value: marker,
},
},
{
name: "user password hash",
model: database.User{
Username: keptField,
Password: marker,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
encoded := marshalModel(t, tc.model)
assert.NotContains(t, encoded, marker)
assert.Contains(t, encoded, keptField)
})
}
}
// TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
// marshalled with its targets preloaded must not carry the credential
// through the association either.
func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
t.Parallel()
const marker = "QQNESTEDMARKERQQ"
encoded := marshalModel(t, database.Webhook{
Name: keptField,
Targets: []database.Target{{
Name: "slack",
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
}},
})
assert.NotContains(t, encoded, marker)
assert.Contains(t, encoded, keptField)
}

View File

@@ -4,5 +4,8 @@ package database
// Used for auto-generated values like the session encryption key. // Used for auto-generated values like the session encryption key.
type Setting struct { type Setting struct {
Key string `gorm:"primaryKey" json:"key"` Key string `gorm:"primaryKey" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
// Value holds the session encryption key, so it is never
// marshalled with the model.
Value string `gorm:"type:text;not null" json:"-"`
} }

View File

@@ -20,8 +20,14 @@ type Target struct {
Type TargetType `gorm:"not null" json:"type"` Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Configuration fields (JSON stored based on type) // Configuration fields (JSON stored based on type).
Config string `gorm:"type:text" json:"config"` // JSON configuration //
// json:"-" because the blob holds the target's credential — a
// Slack incoming-webhook URL, or an http destination whose path
// segments are the secret. delivery.TargetView is the masking
// barrier for the HTML path; this tag is the barrier for any
// handler that marshals the model itself.
Config string `gorm:"type:text" json:"-"` // JSON configuration
// For HTTP targets (max_retries=0 means fire-and-forget, // For HTTP targets (max_retries=0 means fire-and-forget,
// >0 enables retries with backoff) // >0 enables retries with backoff)

View File

@@ -39,8 +39,10 @@ func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
return return
} }
username := r.FormValue("username") // PostFormValue, not FormValue: the credential must come
password := r.FormValue("password") // from the body, never from the query string.
username := r.PostFormValue("username")
password := r.PostFormValue("password")
// Validate input // Validate input
if username == "" || password == "" { if username == "" || password == "" {

View File

@@ -44,9 +44,11 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
successMessage, errorMessage, handled := h.applyPasswordChange( successMessage, errorMessage, handled := h.applyPasswordChange(
w, w,
sessionUsername, sessionUsername,
r.FormValue("current_password"), // PostFormValue, not FormValue: the credential must
r.FormValue("new_password"), // come from the body, never from the query string.
r.FormValue("confirm_password"), r.PostFormValue("current_password"),
r.PostFormValue("new_password"),
r.PostFormValue("confirm_password"),
) )
if !handled { if !handled {
return return

View File

@@ -227,9 +227,9 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
return return
} }
name := r.FormValue("name") name := r.PostFormValue("name")
description := r.FormValue("description") description := r.PostFormValue("description")
retentionStr := r.FormValue("retention_days") retentionStr := r.PostFormValue("retention_days")
if name == "" { if name == "" {
w.WriteHeader(http.StatusBadRequest) w.WriteHeader(http.StatusBadRequest)
@@ -509,7 +509,7 @@ func (h *Handlers) applyWebhookEdit(
) { ) {
// The body size cap is enforced by the MaxBodySize middleware, // The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form. // which runs before CSRF parses the form.
name := r.FormValue("name") name := r.PostFormValue("name")
if name == "" { if name == "" {
data := map[string]any{ data := map[string]any{
tmplKeyWebhook: webhook, tmplKeyWebhook: webhook,
@@ -523,12 +523,12 @@ func (h *Handlers) applyWebhookEdit(
} }
webhook.Name = name webhook.Name = name
webhook.Description = r.FormValue("description") webhook.Description = r.PostFormValue("description")
// An empty field falls back to the stored value, so submitting the // An empty field falls back to the stored value, so submitting the
// form without touching retention leaves the policy alone. // form without touching retention leaves the policy alone.
retentionDays, retErr := parseRetentionDays( retentionDays, retErr := parseRetentionDays(
r.FormValue("retention_days"), webhook.RetentionDays, r.PostFormValue("retention_days"), webhook.RetentionDays,
) )
if retErr != nil { if retErr != nil {
data := map[string]any{ data := map[string]any{
@@ -950,7 +950,7 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
return return
} }
description := r.FormValue("description") description := r.PostFormValue("description")
entrypoint := &database.Entrypoint{ entrypoint := &database.Entrypoint{
WebhookID: webhook.ID, WebhookID: webhook.ID,
@@ -1020,11 +1020,18 @@ func (h *Handlers) processTargetCreate(
) { ) {
// The body size cap is enforced by the MaxBodySize middleware, // The body size cap is enforced by the MaxBodySize middleware,
// which runs before CSRF parses the form. // which runs before CSRF parses the form.
name := r.FormValue("name") //
targetType := database.TargetType(r.FormValue("type")) // Every field here is read with PostFormValue, not FormValue.
targetURL := r.FormValue("url") // FormValue falls back to the query string, which would let
maxRetriesStr := r.FormValue("max_retries") // `POST /source/{id}/targets?url=https://hooks.slack.com/...`
expiry := r.FormValue("expiry") // configure a target from a value the request line carries — and
// the request line, unlike the body, is what logs, proxies,
// Referer headers and error trackers record.
name := r.PostFormValue("name")
targetType := database.TargetType(r.PostFormValue("type"))
targetURL := r.PostFormValue("url")
maxRetriesStr := r.PostFormValue("max_retries")
expiry := r.PostFormValue("expiry")
if name == "" { if name == "" {
http.Error( http.Error(

View File

@@ -0,0 +1,206 @@
package handlers_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"testing"
"github.com/go-chi/chi"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/middleware"
)
// targetSecretSegments are the path segments of an incoming-webhook
// URL. For Slack, Discord and Teams the path IS the bearer credential,
// so this string must not reach storage or the access log by way of
// the request line.
const targetSecretSegments = "T00000000/B00000000/QQTARGETSECRETQQ"
// targetSecretURL is a destination whose secret lives in its path. It
// uses a literal public address rather than a hostname so the SSRF
// check resolves nothing: with a hostname, a sandbox without DNS would
// reject the URL for the wrong reason and the test would pass even
// with the defect reintroduced.
const targetSecretURL = "https://93.184.216.34/services/" +
targetSecretSegments
// targetsForWebhook returns every target stored against a webhook.
func targetsForWebhook(
t *testing.T,
db *database.Database,
webhookID string,
) []database.Target {
t.Helper()
var targets []database.Target
require.NoError(
t,
db.DB().Where("webhook_id = ?", webhookID).
Find(&targets).Error,
)
return targets
}
// postTargetCreate drives HandleTargetCreate through the production
// access-log middleware and a chi route, so the logged url field is
// produced exactly as it ships, and returns the recorder plus the
// captured log.
func postTargetCreate(
t *testing.T,
env *sourceTestEnv,
webhookID string,
query string,
form url.Values,
) (*httptest.ResponseRecorder, string) {
t.Helper()
logBuf := new(bytes.Buffer)
mw := middleware.NewForTest(
slog.New(slog.NewJSONHandler(
logBuf, &slog.HandlerOptions{Level: slog.LevelInfo},
)),
&config.Config{Environment: config.EnvironmentDev},
nil,
)
router := chi.NewRouter()
router.Use(mw.Logging())
router.Post(
"/source/{sourceID}/targets",
env.handlers.HandleTargetCreate(),
)
target := "/source/" + webhookID + "/targets"
if query != "" {
target += "?" + query
}
body := ""
if form != nil {
body = form.Encode()
}
req := httptest.NewRequestWithContext(
context.Background(),
http.MethodPost,
target,
strings.NewReader(body),
)
req.Header.Set(
"Content-Type", "application/x-www-form-urlencoded",
)
for _, c := range env.cookies {
req.AddCookie(c)
}
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
return w, logBuf.String()
}
// TestHandleTargetCreate_QueryStringURLDoesNotConfigureATarget is the
// regression test for the ingress leak. r.FormValue falls back to the
// query string when a field is absent from the POST body, so
//
// POST /source/{id}/targets?url=https://hooks.slack.com/services/...
//
// with an empty url field used to create a working target from a value
// carried on the request line — where logs, proxies, Referer headers
// and error trackers record it. The handler reads the body only, so
// the request is rejected for a missing URL and stores nothing.
//
// name and type are sent in the BODY on purpose: the request has to
// get past those two validations for the assertion to be about the url
// read specifically.
func TestHandleTargetCreate_QueryStringURLDoesNotConfigureATarget(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
body := url.Values{}
body.Set("name", "leaky")
body.Set("type", string(database.TargetTypeSlack))
w, logged := postTargetCreate(
t, env, webhook.ID,
"url="+url.QueryEscape(targetSecretURL),
body,
)
assert.Equal(t, http.StatusBadRequest, w.Code)
targets := targetsForWebhook(t, env.db, webhook.ID)
assert.Empty(
t, targets,
"a query-string value must not populate a target config",
)
assert.NotContains(t, logged, targetSecretSegments)
assert.NotContains(t, logged, "93.184.216.34")
assert.NotEmpty(t, logged, "the access log line must still be written")
}
// TestHandleTargetCreate_BodyURLStillCreatesTheTarget is the positive
// control for the test above: the rejection has to come from where the
// value was read, not from the handler being broken.
func TestHandleTargetCreate_BodyURLStillCreatesTheTarget(t *testing.T) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
body := url.Values{}
body.Set("name", "legit")
body.Set("type", string(database.TargetTypeSlack))
body.Set("url", targetSecretURL)
w, logged := postTargetCreate(t, env, webhook.ID, "", body)
assert.Equal(t, http.StatusSeeOther, w.Code)
targets := targetsForWebhook(t, env.db, webhook.ID)
require.Len(t, targets, 1)
assert.Contains(t, targets[0].Config, targetSecretSegments)
// The body carried the credential, so the access log must still
// not have it: the log records the request line only.
assert.NotContains(t, logged, targetSecretSegments)
}
// TestHandleTargetCreate_QueryStringCannotSupplyNameOrType covers the
// rest of the converted reads on this handler in one request: with an
// empty body, nothing the query carries is visible to it.
func TestHandleTargetCreate_QueryStringCannotSupplyNameOrType(
t *testing.T,
) {
t.Parallel()
env := setupSourceTest(t)
webhook := seedWebhookWithRetention(t, env.db, 30)
w, _ := postTargetCreate(
t, env, webhook.ID,
"name=leaky&type=slack&max_retries=9&expiry=30d&url="+
url.QueryEscape(targetSecretURL),
url.Values{},
)
assert.Equal(t, http.StatusBadRequest, w.Code)
assert.Contains(t, w.Body.String(), "Name is required")
assert.Empty(t, targetsForWebhook(t, env.db, webhook.ID))
}

View File

@@ -4,6 +4,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"github.com/getsentry/sentry-go"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
@@ -13,6 +14,16 @@ import (
// build requests that sit exactly at, below, and above it. // build requests that sit exactly at, below, and above it.
const MaxFormBodySizeForTest = maxFormBodySize 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 // NewRouterForTest builds the real route tree via SetupRoutes with
// the supplied middleware and handlers, bypassing the fx lifecycle // the supplied middleware and handlers, bypassing the fx lifecycle
// and the HTTP listener. Tests use it so that route-group middleware // 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.Appname,
s.params.Globals.Version, 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 { if err != nil {
s.log.Error("sentry init failure", "error", err) s.log.Error("sentry init failure", "error", err)