All checks were successful
check / check (push) Successful in 3m16s
Two places decided whether a request was TLS, by two different means, and they disagreed. The session cookie's Secure attribute was fixed at startup from !Config.IsDev(). "dev" is the environment when WEBHOOKER_ENVIRONMENT is unset, so a deployment terminating TLS at a proxy without also setting the environment shipped the authentication cookie with no Secure attribute -- on the same response as a CSRF cookie that had one. It failed silently: everything kept working, so nothing prompted anyone to look. The CSRF middleware's per-request check compared X-Forwarded-Proto with == "https" exactly, so "HTTPS", "https, http" and "https,https" all took the plaintext path. Uppercase is legal for a case-insensitive token and the comma forms are what a proxy chained behind another proxy emits by appending rather than replacing. On that path gorilla/csrf stops enforcing the strict Referer check on a site that genuinely is HTTPS. Both now go through internal/reqtls.IsTLS, which folds case and takes the leftmost comma-separated element -- the hop nearest the client, and so the one a cookie's Secure attribute is about. A third package is needed because internal/middleware already imports internal/session, so session cannot import middleware back. Per-request beat a startup warning for the session cookie because it turned out to need no restructuring: gorilla/sessions gives every session its own copy of the store's Options and renders the cookie from that copy, and every session-cookie write here already goes through Session.Save or Session.Regenerate, both of which hold the request. The store's template Secure becomes true so that a write path added later which forgets to track the transport fails visibly instead of silently dropping Secure. The flag tracks the transport in both directions rather than latching on. Secure over plaintext is discarded by the browser without an error, which would make a plain-HTTP local run impossible to log into -- and would also void the deletion cookies in Destroy and Regenerate, leaving a session the user just tried to end still live. A third site that makes this decision, internal/handlers' BaseURL construction, assigns the raw header straight into the URL scheme. It is left alone here and filed separately.
240 lines
7.9 KiB
Go
240 lines
7.9 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
|
|
"github.com/getsentry/sentry-go"
|
|
"github.com/go-chi/chi"
|
|
)
|
|
|
|
// 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)"
|
|
|
|
// sentryRedactedPath is what stands in for the request path when the
|
|
// route pattern is not reachable. It is deliberately not the concrete
|
|
// path: on the receiver route that path carries the entrypoint UUID,
|
|
// which is a write capability rather than an identifier.
|
|
const sentryRedactedPath = "/" + sentryRedacted
|
|
|
|
// 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.
|
|
//
|
|
// URL is the third such field. NewRequest builds it as
|
|
// scheme://host/path (interfaces.go:183), and on the receiver route
|
|
// that path is /webhook/<uuid> in full — a write capability, not an
|
|
// identifier. It is rebuilt here from the chi route pattern, on every
|
|
// route, keeping the scheme and the host.
|
|
//
|
|
// 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,
|
|
hint *sentry.EventHint,
|
|
) *sentry.Event {
|
|
if event == nil {
|
|
return event
|
|
}
|
|
|
|
pattern := sentryRoutePattern(hint)
|
|
|
|
// Only transaction events carry a Transaction name, and the SDK
|
|
// builds it from the concrete path too (sentryhttp.go:105 via
|
|
// tracing.go:553). Rewritten on the same terms.
|
|
if event.Transaction != "" {
|
|
event.Transaction = sentryTransactionName(
|
|
event.Transaction, pattern,
|
|
)
|
|
}
|
|
|
|
if event.Request == nil {
|
|
return event
|
|
}
|
|
|
|
req := event.Request
|
|
|
|
if req.URL != "" {
|
|
req.URL = sentryRouteURL(req.URL, pattern)
|
|
}
|
|
|
|
if req.QueryString != "" {
|
|
req.QueryString = sentryRedacted
|
|
}
|
|
|
|
if req.Data != "" {
|
|
req.Data = sentryRedacted
|
|
}
|
|
|
|
req.Cookies = ""
|
|
req.Env = nil
|
|
req.Headers = keptSentryHeaders(req.Headers)
|
|
|
|
return event
|
|
}
|
|
|
|
// sentryRoutePattern returns the chi route pattern for the request the
|
|
// hint carries, or "" when it is not reachable.
|
|
//
|
|
// The request is reachable on the error dispatch only. sentryhttp's
|
|
// recover path calls RecoverWithContext with the request on the
|
|
// context under sentry.RequestContextKey (sentryhttp.go:124-125), and
|
|
// the client copies that context onto the hint (client.go:484-485)
|
|
// before handing it to BeforeSend (client.go:631). chi's routing
|
|
// context is a pointer placed on the request context before the
|
|
// middleware chain runs (chi mux.go:84) and filled in as the mux
|
|
// routes, so by the time a handler panics it names the matched route.
|
|
//
|
|
// The transaction dispatch has no such request: Span.doFinish calls
|
|
// hub.CaptureEvent (tracing.go:356), which passes a nil hint that the
|
|
// client replaces with an empty one (client.go:620-622). The pattern
|
|
// is therefore always "" there, and the callers fall back.
|
|
func sentryRoutePattern(hint *sentry.EventHint) string {
|
|
if hint == nil || hint.Context == nil {
|
|
return ""
|
|
}
|
|
|
|
req, ok := hint.Context.Value(
|
|
sentry.RequestContextKey,
|
|
).(*http.Request)
|
|
if !ok || req == nil {
|
|
return ""
|
|
}
|
|
|
|
rctx := chi.RouteContext(req.Context())
|
|
if rctx == nil {
|
|
return ""
|
|
}
|
|
|
|
// Empty when no route matched, which is the fallback case too.
|
|
return rctx.RoutePattern()
|
|
}
|
|
|
|
// sentryRouteURL rebuilds an event's request URL with the route
|
|
// pattern in place of the concrete path.
|
|
//
|
|
// The scheme is load-bearing and is kept: the SDK derives it from
|
|
// r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https"
|
|
// (interfaces.go:180), which is the reason dropping X-Forwarded-Proto
|
|
// from the header allowlist costs nothing. That predicate is the SDK's
|
|
// own and is stricter than reqtls.IsTLS, which this service now uses
|
|
// everywhere it decides transport: the SDK reports "http" for the
|
|
// "HTTPS" and "https, http" spellings reqtls accepts. Only a reported
|
|
// scheme is affected, no decision is, so it is left to the SDK rather
|
|
// than reimplemented. The host is parsed.Host of the SDK's
|
|
// scheme://r.Host/path, so it is whatever the client's Host header
|
|
// carried: this service validates no hostname. It is kept because that
|
|
// same header is on the allowlist, so scrubbing it here would withhold
|
|
// nothing that is not sent anyway.
|
|
//
|
|
// Everything else in the URL is discarded rather than edited, so a
|
|
// future SDK that starts appending a query string cannot widen this.
|
|
func sentryRouteURL(rawURL, pattern string) string {
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Scheme == "" {
|
|
// Not a shape this can safely take apart.
|
|
return sentryRedacted
|
|
}
|
|
|
|
if pattern == "" {
|
|
pattern = sentryRedactedPath
|
|
}
|
|
|
|
return parsed.Scheme + "://" + parsed.Host + pattern
|
|
}
|
|
|
|
// sentryTransactionName rebuilds the SDK's "METHOD /path" transaction
|
|
// name with the route pattern in place of the concrete path. Method is
|
|
// kept for the same reason Request.Method is: net/http admits only a
|
|
// bounded token there. A name in any other shape is withheld whole,
|
|
// since nothing can be said about which part of it is a path.
|
|
func sentryTransactionName(name, pattern string) string {
|
|
method, _, found := strings.Cut(name, " ")
|
|
if !found {
|
|
return sentryRedacted
|
|
}
|
|
|
|
if pattern == "" {
|
|
pattern = sentryRedactedPath
|
|
}
|
|
|
|
return method + " " + pattern
|
|
}
|
|
|
|
// 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
|
|
}
|
|
}
|