Read form fields from the POST body only (closes #160)
All checks were successful
check / check (push) Successful in 2m53s
All checks were successful
check / check (push) Successful in 2m53s
r.FormValue falls back to the query string, so
POST /source/{id}/targets?url=<secret> created a working target from a
value carried on the request line — where proxy logs, browser history
and Referer all record it. Every form read is now r.PostFormValue,
including the login password and both password-change fields, which had
the same defect in a more acute form.
The Sentry leg needed more than the query string. sentryhttp attaches
the whole request to the scope, and ApplyToEvent copies the teed body
into Request.Data with no SendDefaultPII guard — so reading every field
from the body only pointed every credential this change protects at the
one field the first revision did not scrub. Body and query are now
redacted, Cookies and Env cleared, and Headers reduced to an allowlist,
because the SDK's own filter removes four names and would otherwise ship
X-Csrf-Token and the shared secrets senders put on the receiver route.
Also adds json:"-" to Target.Config, APIKey.Key and Setting.Value —
TargetView is the masking barrier for the HTML path only, and the first
handler to marshal a model would serialise a bearer token or the session
encryption key.
Independently reviewed three times. The second review found the Data
leak and proved it with a scratch module; the third disproved the
PR's own claim that BeforeSend gets no request, so the README now
records that redacting unconditionally is a deliberate choice rather
than a limitation — which is what makes #179 cheap to fix.
This commit was merged in pull request #174.
This commit is contained in:
117
internal/server/sentry.go
Normal file
117
internal/server/sentry.go
Normal file
@@ -0,0 +1,117 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/getsentry/sentry-go"
|
||||
)
|
||||
|
||||
// sentryRedacted stands in for a withheld field on every event shipped
|
||||
// to Sentry. It is a marker rather than an empty string so a reader
|
||||
// can tell a suppressed value from an absent one.
|
||||
const sentryRedacted = "(redacted)"
|
||||
|
||||
// sentryClientOptions builds the options the SDK is initialised with.
|
||||
// It is its own function so a test can stand up a client wired exactly
|
||||
// as production is, with only the transport swapped.
|
||||
func sentryClientOptions(dsn, release string) sentry.ClientOptions {
|
||||
return sentry.ClientOptions{
|
||||
Dsn: dsn,
|
||||
Release: release,
|
||||
// Both hooks, because the SDK runs one for error events
|
||||
// and the other for transactions.
|
||||
BeforeSend: scrubSentryRequest,
|
||||
BeforeSendTransaction: scrubSentryRequest,
|
||||
}
|
||||
}
|
||||
|
||||
// scrubSentryRequest strips client-supplied content from an event's
|
||||
// request context before it leaves the process.
|
||||
//
|
||||
// sentryhttp attaches the whole *http.Request to the scope
|
||||
// (sentryhttp.go:113), and Scope.ApplyToEvent fills the event's
|
||||
// Request from it inside prepareEvent, which runs before this hook.
|
||||
// Two of the fields it fills are copied with no SendDefaultPII guard:
|
||||
//
|
||||
// - QueryString, verbatim from r.URL.RawQuery.
|
||||
// - Data, the first 10 KiB of the request body, teed off r.Body by
|
||||
// SetRequest and filled precisely because the handlers call
|
||||
// ParseForm.
|
||||
//
|
||||
// Since every form field in this service is read with PostFormValue,
|
||||
// the body is the only place a credential is submitted: a target's
|
||||
// destination URL, whose path segments are the bearer token, plus the
|
||||
// login password and both password-change fields. None of that may
|
||||
// reach a third-party service.
|
||||
//
|
||||
// This hook is a floor, not a default: the fields it clears stay
|
||||
// cleared even if SendDefaultPII is ever turned on.
|
||||
func scrubSentryRequest(
|
||||
event *sentry.Event,
|
||||
_ *sentry.EventHint,
|
||||
) *sentry.Event {
|
||||
if event == nil || event.Request == nil {
|
||||
return event
|
||||
}
|
||||
|
||||
req := event.Request
|
||||
|
||||
if req.QueryString != "" {
|
||||
req.QueryString = sentryRedacted
|
||||
}
|
||||
|
||||
if req.Data != "" {
|
||||
req.Data = sentryRedacted
|
||||
}
|
||||
|
||||
req.Cookies = ""
|
||||
req.Env = nil
|
||||
req.Headers = keptSentryHeaders(req.Headers)
|
||||
|
||||
return event
|
||||
}
|
||||
|
||||
// keptSentryHeaders returns the subset of headers an event may carry
|
||||
// off-host. Dropping by allowlist rather than by blocklist is what
|
||||
// makes an unrecognised header safe: the SDK's own filter removes four
|
||||
// names and passes everything else, so X-Csrf-Token — which
|
||||
// gorilla/csrf accepts in place of the form field — and the shared
|
||||
// secrets senders put on the receiver route (X-Gitlab-Token and the
|
||||
// per-provider signature headers) would otherwise ship verbatim.
|
||||
func keptSentryHeaders(headers map[string]string) map[string]string {
|
||||
if len(headers) == 0 {
|
||||
return headers
|
||||
}
|
||||
|
||||
kept := make(map[string]string, len(headers))
|
||||
|
||||
for name, value := range headers {
|
||||
if sentryKeepsHeader(name) {
|
||||
kept[name] = value
|
||||
}
|
||||
}
|
||||
|
||||
return kept
|
||||
}
|
||||
|
||||
// sentryKeepsHeader reports whether a request header is routing or
|
||||
// content metadata rather than client-chosen payload. Referer is kept
|
||||
// on the reasoning that it is browser-set, that this service emits
|
||||
// only ?page= in its own links, and that Referrer-Policy is set to
|
||||
// strict-origin-when-cross-origin. X-Request-Id ties the event to the
|
||||
// local access log line, which holds the rest of the detail.
|
||||
func sentryKeepsHeader(name string) bool {
|
||||
switch http.CanonicalHeaderKey(name) {
|
||||
case "Accept",
|
||||
"Content-Length",
|
||||
"Content-Type",
|
||||
"Host",
|
||||
"Origin",
|
||||
"Referer",
|
||||
"User-Agent",
|
||||
"X-Request-Id":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user