Compare commits
2
Commits
96d8e7c1aa
...
3ddc1a3a02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ddc1a3a02 | ||
|
|
957de7f99f |
@@ -141,6 +141,7 @@ security headers are always applied.
|
|||||||
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
|
| `SENTRY_DSN` | Sentry error reporting DSN. Unset leaves error reporting off; a value the Sentry SDK cannot parse fails startup rather than serving with reporting silently off | `""` |
|
||||||
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` |
|
| `RETENTION_SWEEP_INTERVAL` | How often the retention reaper and archive sweeper run (Go duration, must be positive). A value that does not parse, or is zero or negative, fails startup | `1h` |
|
||||||
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
| `SESSION_IDLE_TIMEOUT` | Idle session timeout (Go duration) | `24h` |
|
||||||
|
| `HSTS_MAX_AGE` | `max-age` of the `Strict-Transport-Security` header, which also carries `includeSubDomains` and `preload` (Go duration, whole seconds). `0` sends no header. Read [HSTS](#hsts) before changing it | `87600h` (ten years) |
|
||||||
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
| `RECEIVER_RATE_LIMIT` | Receiver requests/minute per IP per entrypoint (10x that per IP across the route) | `120` |
|
||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
@@ -483,9 +484,10 @@ crash the reaper after startup), every entry in `TRUSTED_PROXIES` and
|
|||||||
`ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address, and
|
`ALLOWED_EGRESS_CIDRS` must be a CIDR block or a bare IP address, and
|
||||||
`BIND_ADDRESS` must be an IP address literal — `localhost`, `127.0.0.1:8080` and
|
`BIND_ADDRESS` must be an IP address literal — `localhost`, `127.0.0.1:8080` and
|
||||||
`10.0.0.0/8` are each rejected rather than resolved, split, or narrowed to
|
`10.0.0.0/8` are each rejected rather than resolved, split, or narrowed to
|
||||||
something they do not say — and `SENTRY_DSN` must parse as a Sentry DSN.
|
something they do not say — `SENTRY_DSN` must parse as a Sentry DSN, and
|
||||||
`SESSION_IDLE_TIMEOUT` is the exception: a non-positive value there means idle
|
`HSTS_MAX_AGE` must be a whole number of seconds, zero or more (`1.5s` and `-1h`
|
||||||
expiry is disabled, not invalid.
|
are rejected). `SESSION_IDLE_TIMEOUT` is the exception: a non-positive value
|
||||||
|
there means idle expiry is disabled, not invalid.
|
||||||
|
|
||||||
`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call the SDK
|
`SENTRY_DSN` is checked with the Sentry SDK's own parser, the same call the SDK
|
||||||
makes on the DSN it is later handed, so what configuration accepts is exactly
|
makes on the DSN it is later handed, so what configuration accepts is exactly
|
||||||
@@ -882,17 +884,39 @@ If nginx runs on another host, `BIND_ADDRESS` becomes the address it connects
|
|||||||
to, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled
|
to, `TRUSTED_PROXIES` becomes nginx's address, and the port must be firewalled
|
||||||
to that address — the traffic between them is cleartext.
|
to that address — the traffic between them is cleartext.
|
||||||
|
|
||||||
### HSTS is always sent, and is not configurable
|
### HSTS
|
||||||
|
|
||||||
Every response carries
|
Every response carries a `Strict-Transport-Security` header unless
|
||||||
`Strict-Transport-Security: max-age=63072000; includeSubDomains; preload`. Two
|
`HSTS_MAX_AGE` is `0`. With the default `HSTS_MAX_AGE` of `87600h` (ten years)
|
||||||
years, every subdomain, and a `preload` token. There is no setting that changes
|
it is:
|
||||||
or suppresses it.
|
|
||||||
|
|
||||||
This is worth knowing before the first request reaches a browser: a client that
|
```
|
||||||
sees it once will refuse plaintext HTTP to that hostname — and to every
|
Strict-Transport-Security: max-age=315360000; includeSubDomains; preload
|
||||||
subdomain of it — for two years, whatever else is served there. Terminate TLS on
|
```
|
||||||
a hostname you are prepared to keep on HTTPS.
|
|
||||||
|
`HSTS_MAX_AGE` sets `max-age`, in seconds. `includeSubDomains` and `preload` are
|
||||||
|
always part of the header. A browser that receives the header over HTTPS refuses
|
||||||
|
plaintext HTTP to that hostname until `max-age` runs out, whatever else is
|
||||||
|
served there, so terminate TLS on a hostname you are prepared to keep on HTTPS.
|
||||||
|
Browsers ignore the header when it arrives over plain HTTP.
|
||||||
|
|
||||||
|
`includeSubDomains` extends this to every subdomain of the hostname webhooker is
|
||||||
|
served at. Served at `hooks.example.com`, it covers every name under
|
||||||
|
`hooks.example.com`; served at `example.com`, every name under `example.com`.
|
||||||
|
|
||||||
|
`preload` invites browsers to put the domain on their built-in HSTS preload
|
||||||
|
lists. The token alone puts nothing there. A domain is listed after someone
|
||||||
|
submits it at hstspreload.org, which accepts only a whole registered domain such
|
||||||
|
as `example.com`, not `hooks.example.com`. A listed domain is HTTPS-only in
|
||||||
|
every browser that ships the list, even for users who have never visited it, and
|
||||||
|
removal takes months to reach those browsers, so treat a listing as permanent.
|
||||||
|
|
||||||
|
Setting `HSTS_MAX_AGE=0` stops the header being sent, but a browser that already
|
||||||
|
received it over HTTPS does not forget it: it keeps refusing plaintext HTTP
|
||||||
|
until the `max-age` it saw runs out. Each header a browser receives over HTTPS
|
||||||
|
replaces the `max-age` it stored, so to end the policy sooner, set a short
|
||||||
|
`HSTS_MAX_AGE` such as `1s` and keep it until returning browsers have picked it
|
||||||
|
up, or have a reverse proxy send `max-age=0`.
|
||||||
|
|
||||||
## Backup, Restore, and Upgrades
|
## Backup, Restore, and Upgrades
|
||||||
|
|
||||||
@@ -2527,10 +2551,23 @@ Where the pattern is out of reach — the transaction dispatch, an event capture
|
|||||||
outside the router, or a request that matched no route — the fallback is never
|
outside the router, or a request that matched no route — the fallback is never
|
||||||
the concrete path. The path becomes the literal `/(redacted)`, so the URL reads
|
the concrete path. The path becomes the literal `/(redacted)`, so the URL reads
|
||||||
`http://host/(redacted)`; a URL the rewrite cannot parse into a scheme is
|
`http://host/(redacted)`; a URL the rewrite cannot parse into a scheme is
|
||||||
withheld whole. A transaction event additionally carries the SDK's own
|
withheld whole.
|
||||||
`METHOD /path` name, built from the concrete path as well; it is rewritten on
|
|
||||||
the same terms, to `POST /h/{uuid}` where the pattern is known and
|
A transaction event also carries a name, which the SDK builds as `METHOD /path`
|
||||||
`POST /(redacted)` where it is not.
|
from the concrete path and gives the transaction source `url`. Sentry groups
|
||||||
|
transactions by name, so withholding it would put every request in the service
|
||||||
|
into one group per method, such as `POST /(redacted)`. Inside each Sentry
|
||||||
|
handler, a second middleware renames the transaction once the request has been
|
||||||
|
routed, to the method and the route pattern (`POST /h/{uuid}`), and sets its
|
||||||
|
source to `route`. The hook keeps a name whose source is `route` and rewrites
|
||||||
|
any other to `METHOD /(redacted)`. A path that matches nothing at the top level
|
||||||
|
has no pattern, so its transaction keeps the SDK's name and the hook withholds
|
||||||
|
it. Inside a route group, a path that matches no route in the group, and a
|
||||||
|
request the group's own middleware turns away before routing (a CSRF refusal,
|
||||||
|
the redirect to `/pages/login`), are named after the group's pattern, such as
|
||||||
|
`GET /pages/*` or `POST /hook/{sourceID}/*`, and the hook keeps that name. The
|
||||||
|
URL on a transaction event still falls back to `http://host/(redacted)`, because
|
||||||
|
the hook has no request there.
|
||||||
|
|
||||||
The headers are an allowlist for the same reason the rules above are
|
The headers are an allowlist for the same reason the rules above are
|
||||||
unconditional: the SDK's own filter removes four names and passes everything
|
unconditional: the SDK's own filter removes four names and passes everything
|
||||||
@@ -3139,9 +3176,9 @@ any database — it only writes to record results.
|
|||||||
Applied to all routes in this order:
|
Applied to all routes in this order:
|
||||||
|
|
||||||
1. **RequestID** — Generate unique request IDs (chi built-in)
|
1. **RequestID** — Generate unique request IDs (chi built-in)
|
||||||
2. **SecurityHeaders** — Production security headers on every response (HSTS,
|
2. **SecurityHeaders** — Production security headers on every response (HSTS
|
||||||
X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
unless `HSTS_MAX_AGE` is `0`, X-Content-Type-Options, X-Frame-Options, CSP,
|
||||||
Permissions-Policy)
|
Referrer-Policy, Permissions-Policy)
|
||||||
3. **Logging** — Structured request logging (method, URL, status, latency,
|
3. **Logging** — Structured request logging (method, URL, status, latency,
|
||||||
remote IP, client IP, user agent, request ID)
|
remote IP, client IP, user agent, request ID)
|
||||||
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||||
@@ -3151,7 +3188,8 @@ Applied to all routes in this order:
|
|||||||
7. **Recoverer** — Panic recovery: one `ERROR` record through `internal/logger`
|
7. **Recoverer** — Panic recovery: one `ERROR` record through `internal/logger`
|
||||||
and a `500`
|
and a `500`
|
||||||
8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; configured
|
8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set; configured
|
||||||
with `Repanic: true` so panics still reach Recoverer)
|
with `Repanic: true` so panics still reach Recoverer), and inside it the
|
||||||
|
naming of the request's Sentry transaction after its route pattern
|
||||||
|
|
||||||
Recoverer sits seventh rather than first, and both neighbours are the reason. It
|
Recoverer sits seventh rather than first, and both neighbours are the reason. It
|
||||||
runs **inside** everything that observes the response, so the `500` it writes
|
runs **inside** everything that observes the response, so the `500` it writes
|
||||||
@@ -3166,9 +3204,9 @@ set a header or start a timer.
|
|||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
||||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
|
||||||
own **Sentry** error reporting. That Recoverer answers a panic with the `500`
|
own **Sentry** error reporting and transaction naming. That Recoverer answers a
|
||||||
error page in the normal layout; the global one keeps the plain-text `500` for
|
panic with the `500` error page in the normal layout; the global one keeps the
|
||||||
every other route.
|
plain-text `500` for every other route.
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
||||||
`/hook/*`) apply a **MaxBodySize** middleware that limits POST/PUT/PATCH request
|
`/hook/*`) apply a **MaxBodySize** middleware that limits POST/PUT/PATCH request
|
||||||
@@ -3222,9 +3260,9 @@ credential check, see [The login endpoint](#the-login-endpoint).
|
|||||||
- Session regeneration on login to prevent session fixation attacks
|
- Session regeneration on login to prevent session fixation attacks
|
||||||
- Session key is a 32-byte value auto-generated on first startup and stored in
|
- Session key is a 32-byte value auto-generated on first startup and stored in
|
||||||
the database
|
the database
|
||||||
- Production security headers on all responses: HSTS, X-Content-Type-Options
|
- Production security headers on all responses: HSTS (unless `HSTS_MAX_AGE` is
|
||||||
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy,
|
`0`), X-Content-Type-Options (`nosniff`), X-Frame-Options (`DENY`),
|
||||||
Referrer-Policy, and Permissions-Policy
|
Content-Security-Policy, Referrer-Policy, and Permissions-Policy
|
||||||
- Request body size limits (1 MB) on all form POST endpoints, enforced by
|
- Request body size limits (1 MB) on all form POST endpoints, enforced by
|
||||||
middleware that runs before CSRF parses the form
|
middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf) on all
|
||||||
|
|||||||
@@ -68,6 +68,10 @@ const (
|
|||||||
// authenticated activity before it expires.
|
// authenticated activity before it expires.
|
||||||
defaultSessionIdleTimeout = 24 * time.Hour
|
defaultSessionIdleTimeout = 24 * time.Hour
|
||||||
|
|
||||||
|
// defaultHSTSMaxAge is the max-age of the Strict-Transport-Security
|
||||||
|
// header when HSTS_MAX_AGE is unset: ten years.
|
||||||
|
defaultHSTSMaxAge = 87600 * time.Hour
|
||||||
|
|
||||||
// defaultReceiverRateLimit is the default number of requests
|
// defaultReceiverRateLimit is the default number of requests
|
||||||
// per minute each client IP may send to a single webhook
|
// per minute each client IP may send to a single webhook
|
||||||
// receiver entrypoint. Generous for legitimate webhook
|
// receiver entrypoint. Generous for legitimate webhook
|
||||||
@@ -120,6 +124,10 @@ var ErrInvalidBindAddress = errors.New("invalid bind address")
|
|||||||
// the Sentry SDK cannot parse as a DSN.
|
// the Sentry SDK cannot parse as a DSN.
|
||||||
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
|
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
|
||||||
|
|
||||||
|
// ErrInvalidHSTSMaxAge is returned when HSTS_MAX_AGE is set to a
|
||||||
|
// negative duration or one that is not a whole number of seconds.
|
||||||
|
var ErrInvalidHSTSMaxAge = errors.New("invalid HSTS max-age")
|
||||||
|
|
||||||
// ErrDotEnvUnreadable is returned when the optional .env file exists
|
// ErrDotEnvUnreadable is returned when the optional .env file exists
|
||||||
// but cannot be read or parsed. A file that is not there is not an
|
// but cannot be read or parsed. A file that is not there is not an
|
||||||
// error; a file that is there and broken is.
|
// error; a file that is there and broken is.
|
||||||
@@ -169,6 +177,10 @@ type Config struct {
|
|||||||
// which a session expires. Non-positive disables idle expiry.
|
// which a session expires. Non-positive disables idle expiry.
|
||||||
SessionIdleTimeout time.Duration
|
SessionIdleTimeout time.Duration
|
||||||
|
|
||||||
|
// HSTSMaxAge is the max-age of the Strict-Transport-Security
|
||||||
|
// header. Zero means the header is not sent at all.
|
||||||
|
HSTSMaxAge time.Duration
|
||||||
|
|
||||||
// ReceiverRateLimit is the number of requests per minute each
|
// ReceiverRateLimit is the number of requests per minute each
|
||||||
// client IP may send to a single webhook receiver entrypoint.
|
// client IP may send to a single webhook receiver entrypoint.
|
||||||
ReceiverRateLimit int
|
ReceiverRateLimit int
|
||||||
@@ -624,6 +636,27 @@ func resolveEnvironment() (string, error) {
|
|||||||
return environment, nil
|
return environment, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveHSTSMaxAge reads HSTS_MAX_AGE. Zero is allowed and turns the
|
||||||
|
// Strict-Transport-Security header off. A fraction of a second is an
|
||||||
|
// error rather than rounded, because the header carries whole seconds
|
||||||
|
// and would otherwise say something other than what was set.
|
||||||
|
func resolveHSTSMaxAge() (time.Duration, error) {
|
||||||
|
maxAge, err := envDuration("HSTS_MAX_AGE", defaultHSTSMaxAge)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxAge < 0 || maxAge%time.Second != 0 {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: HSTS_MAX_AGE must be zero or a positive whole "+
|
||||||
|
"number of seconds, got %s",
|
||||||
|
ErrInvalidHSTSMaxAge, maxAge,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return maxAge, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveListener reads the two variables that describe the HTTP
|
// resolveListener reads the two variables that describe the HTTP
|
||||||
// listener: which port it claims and which address it claims it on.
|
// listener: which port it claims and which address it claims it on.
|
||||||
// They are read together because neither is meaningful alone, and
|
// They are read together because neither is meaningful alone, and
|
||||||
@@ -684,6 +717,11 @@ func loadFromEnv() (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
hstsMaxAge, err := resolveHSTSMaxAge()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
receiverRateLimit, err := envPositiveInt(
|
receiverRateLimit, err := envPositiveInt(
|
||||||
"RECEIVER_RATE_LIMIT",
|
"RECEIVER_RATE_LIMIT",
|
||||||
defaultReceiverRateLimit,
|
defaultReceiverRateLimit,
|
||||||
@@ -723,6 +761,7 @@ func loadFromEnv() (*Config, error) {
|
|||||||
SentryDSN: sentryDSN,
|
SentryDSN: sentryDSN,
|
||||||
RetentionSweepInterval: retentionSweepInterval,
|
RetentionSweepInterval: retentionSweepInterval,
|
||||||
SessionIdleTimeout: sessionIdleTimeout,
|
SessionIdleTimeout: sessionIdleTimeout,
|
||||||
|
HSTSMaxAge: hstsMaxAge,
|
||||||
ReceiverRateLimit: receiverRateLimit,
|
ReceiverRateLimit: receiverRateLimit,
|
||||||
TrustedProxies: trustedProxies,
|
TrustedProxies: trustedProxies,
|
||||||
AllowedEgressCIDRs: allowedEgressCIDRs,
|
AllowedEgressCIDRs: allowedEgressCIDRs,
|
||||||
@@ -807,6 +846,7 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
// disables idle expiry entirely, and that is worth showing
|
// disables idle expiry entirely, and that is worth showing
|
||||||
// back to the operator.
|
// back to the operator.
|
||||||
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
|
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
|
||||||
|
"hstsMaxAge", s.HSTSMaxAge.String(),
|
||||||
"receiverRateLimit", s.ReceiverRateLimit,
|
"receiverRateLimit", s.ReceiverRateLimit,
|
||||||
"trustedProxies", len(s.TrustedProxies),
|
"trustedProxies", len(s.TrustedProxies),
|
||||||
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
|
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
|
||||||
|
|||||||
@@ -379,6 +379,83 @@ func testSessionIdleTimeoutSuccess(
|
|||||||
assert.Equal(t, expected, cfg.SessionIdleTimeout)
|
assert.Equal(t, expected, cfg.SessionIdleTimeout)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestHSTSMaxAge(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
// sentinel, when set, must be wrapped by the startup
|
||||||
|
// error; every error case must additionally name the
|
||||||
|
// variable in its message.
|
||||||
|
sentinel error
|
||||||
|
expected time.Duration
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: caseUnsetUsesDefault,
|
||||||
|
set: false,
|
||||||
|
expected: 87600 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: caseValidValueParsed,
|
||||||
|
set: true,
|
||||||
|
value: "720h",
|
||||||
|
expected: 720 * time.Hour,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// Zero turns the header off; it is not an error.
|
||||||
|
name: "zero is accepted",
|
||||||
|
set: true,
|
||||||
|
value: "0",
|
||||||
|
expected: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: caseUnparseableFails,
|
||||||
|
set: true,
|
||||||
|
value: "10 years",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// A whole number of seconds, so only the sign is wrong.
|
||||||
|
name: "negative whole seconds fail startup",
|
||||||
|
set: true,
|
||||||
|
value: "-1s",
|
||||||
|
expectError: true,
|
||||||
|
sentinel: config.ErrInvalidHSTSMaxAge,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "fraction of a second fails startup",
|
||||||
|
set: true,
|
||||||
|
value: "1500ms",
|
||||||
|
expectError: true,
|
||||||
|
sentinel: config.ErrInvalidHSTSMaxAge,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
configtest.ClearEnv(t)
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv("HSTS_MAX_AGE", tt.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
expectStartupErrorFor(t, "HSTS_MAX_AGE", tt.sentinel)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := buildConfig(t)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, cfg.HSTSMaxAge)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultDataDir(t *testing.T) {
|
func TestDefaultDataDir(t *testing.T) {
|
||||||
for _, env := range []string{"", "dev", "prod"} {
|
for _, env := range []string{"", "dev", "prod"} {
|
||||||
name := env
|
name := env
|
||||||
|
|||||||
@@ -86,6 +86,13 @@ func settingRows(cfg *config.Config) []settingRow {
|
|||||||
"Idle session timeout (Go duration)",
|
"Idle session timeout (Go duration)",
|
||||||
cfg.SessionIdleTimeout.String(),
|
cfg.SessionIdleTimeout.String(),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"HSTS_MAX_AGE",
|
||||||
|
"max-age of the Strict-Transport-Security header, which " +
|
||||||
|
"also carries includeSubDomains and preload (Go " +
|
||||||
|
"duration, whole seconds). 0 sends no header",
|
||||||
|
cfg.HSTSMaxAge.String(),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"RECEIVER_RATE_LIMIT",
|
"RECEIVER_RATE_LIMIT",
|
||||||
"Receiver requests/minute per IP per entrypoint " +
|
"Receiver requests/minute per IP per entrypoint " +
|
||||||
|
|||||||
@@ -76,6 +76,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
|||||||
BindAddress: "192.0.2.10",
|
BindAddress: "192.0.2.10",
|
||||||
RetentionSweepInterval: 17 * time.Minute,
|
RetentionSweepInterval: 17 * time.Minute,
|
||||||
SessionIdleTimeout: 3 * time.Hour,
|
SessionIdleTimeout: 3 * time.Hour,
|
||||||
|
HSTSMaxAge: 8760 * time.Hour,
|
||||||
ReceiverRateLimit: 77,
|
ReceiverRateLimit: 77,
|
||||||
TrustedProxies: []netip.Prefix{
|
TrustedProxies: []netip.Prefix{
|
||||||
netip.MustParsePrefix("10.1.0.0/16"),
|
netip.MustParsePrefix("10.1.0.0/16"),
|
||||||
@@ -99,6 +100,7 @@ func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
|||||||
"SENTRY_DSN": "not set",
|
"SENTRY_DSN": "not set",
|
||||||
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||||
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||||
|
"HSTS_MAX_AGE": "8760h0m0s",
|
||||||
"RECEIVER_RATE_LIMIT": "77",
|
"RECEIVER_RATE_LIMIT": "77",
|
||||||
"TRUSTED_PROXIES": "10.1.0.0/16",
|
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||||
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -535,18 +536,23 @@ func (s *Middleware) MetricsAuth() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SecurityHeaders returns middleware that sets production security
|
// SecurityHeaders returns middleware that sets production security
|
||||||
// headers on every response: HSTS, X-Content-Type-Options,
|
// headers on every response: HSTS (unless HSTS_MAX_AGE is zero),
|
||||||
// X-Frame-Options, CSP, Referrer-Policy, and Permissions-Policy.
|
// X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy, and
|
||||||
|
// Permissions-Policy.
|
||||||
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||||
|
maxAge := s.params.Config.HSTSMaxAge
|
||||||
|
hsts := "max-age=" + strconv.FormatInt(int64(maxAge/time.Second), 10) +
|
||||||
|
"; includeSubDomains; preload"
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(
|
return http.HandlerFunc(func(
|
||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) {
|
) {
|
||||||
w.Header().Set(
|
if maxAge > 0 {
|
||||||
"Strict-Transport-Security",
|
w.Header().Set("Strict-Transport-Security", hsts)
|
||||||
"max-age=63072000; includeSubDomains; preload",
|
}
|
||||||
)
|
|
||||||
w.Header().Set(
|
w.Header().Set(
|
||||||
"X-Content-Type-Options", "nosniff",
|
"X-Content-Type-Options", "nosniff",
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -745,6 +745,70 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- SecurityHeaders Middleware Tests ---
|
||||||
|
|
||||||
|
// hstsHeaders serves one request through SecurityHeaders built over
|
||||||
|
// a Config with the given HSTSMaxAge, and returns every
|
||||||
|
// Strict-Transport-Security value on the response.
|
||||||
|
func hstsHeaders(t *testing.T, maxAge time.Duration) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
cfg := &config.Config{HSTSMaxAge: maxAge}
|
||||||
|
m := middlewaretest.New(
|
||||||
|
t, slog.New(slog.DiscardHandler), cfg,
|
||||||
|
newTestSessionManager(t, cfg),
|
||||||
|
)
|
||||||
|
|
||||||
|
handler := m.SecurityHeaders()(http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
},
|
||||||
|
))
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/", nil,
|
||||||
|
)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
return w.Header().Values("Strict-Transport-Security")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSecurityHeaders_HSTS(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
maxAge time.Duration
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "default of ten years",
|
||||||
|
maxAge: 87600 * time.Hour,
|
||||||
|
expected: []string{"max-age=315360000; includeSubDomains; preload"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "custom value is sent in seconds",
|
||||||
|
maxAge: 90 * time.Minute,
|
||||||
|
expected: []string{"max-age=5400; includeSubDomains; preload"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero sends no header",
|
||||||
|
maxAge: 0,
|
||||||
|
expected: nil,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
assert.Equal(t, tt.expected, hstsHeaders(t, tt.maxAge))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- MaxBodySize Middleware Tests ---
|
// --- MaxBodySize Middleware Tests ---
|
||||||
|
|
||||||
const testBodyLimit int64 = 64
|
const testBodyLimit int64 = 64
|
||||||
|
|||||||
@@ -91,6 +91,24 @@ func NewRouterForTest(
|
|||||||
return s.router
|
return s.router
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NewRouterWithSentryForTest is NewRouterForTest with the sentryhttp
|
||||||
|
// handler registered, as a set SENTRY_DSN does in production.
|
||||||
|
func NewRouterWithSentryForTest(
|
||||||
|
t *testing.T,
|
||||||
|
log *logger.Logger,
|
||||||
|
cfg *config.Config,
|
||||||
|
mw *middleware.Middleware,
|
||||||
|
h *handlers.Handlers,
|
||||||
|
) http.Handler {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
s := newServerForTest(t, log, cfg, mw, h)
|
||||||
|
s.sentryEnabled.Store(true)
|
||||||
|
s.SetupRoutes()
|
||||||
|
|
||||||
|
return s.router
|
||||||
|
}
|
||||||
|
|
||||||
// ListenAddrForTest exposes the address the HTTP listener binds for
|
// ListenAddrForTest exposes the address the HTTP listener binds for
|
||||||
// a given Config, so the rendering of host and port — IPv6
|
// a given Config, so the rendering of host and port — IPv6
|
||||||
// bracketing above all — can be pinned without standing up a
|
// bracketing above all — can be pinned without standing up a
|
||||||
|
|||||||
@@ -90,8 +90,8 @@ func (s *Server) setupGlobalMiddleware() {
|
|||||||
|
|
||||||
// recoverPanics installs on r the recoverer, answering a panic with
|
// recoverPanics installs on r the recoverer, answering a panic with
|
||||||
// page (a plain 500 when page is nil), and inside it the Sentry error
|
// page (a plain 500 when page is nil), and inside it the Sentry error
|
||||||
// reporting (if SENTRY_DSN is set). Repanic is true so panics still
|
// reporting and transaction naming (if SENTRY_DSN is set). Repanic is
|
||||||
// bubble up to the recoverer.
|
// true so panics still bubble up to the recoverer.
|
||||||
//
|
//
|
||||||
// Each admin page route group installs its own, with the error page,
|
// Each admin page route group installs its own, with the error page,
|
||||||
// as its first middleware. A panic there is logged, reported and
|
// as its first middleware. A panic there is logged, reported and
|
||||||
@@ -105,6 +105,11 @@ func (s *Server) recoverPanics(r chi.Router, page http.Handler) {
|
|||||||
Repanic: true,
|
Repanic: true,
|
||||||
})
|
})
|
||||||
r.Use(sentryHandler.Handle)
|
r.Use(sentryHandler.Handle)
|
||||||
|
// Installed with every sentryhttp handler rather than once
|
||||||
|
// globally: in an admin page route group, the group's own
|
||||||
|
// handler finishes the transaction as the group returns, so
|
||||||
|
// a rename further out would come too late.
|
||||||
|
r.Use(nameSentryTransaction)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package server
|
package server
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -71,10 +72,15 @@ func scrubSentryRequest(
|
|||||||
|
|
||||||
pattern := sentryRoutePattern(hint)
|
pattern := sentryRoutePattern(hint)
|
||||||
|
|
||||||
// Only transaction events carry a Transaction name, and the SDK
|
// Only transaction events carry a Transaction name. sentryhttp
|
||||||
// builds it from the concrete path too (sentryhttp.go:105 via
|
// builds it from the concrete path too (sentryhttp.go:105 via
|
||||||
// tracing.go:553). Rewritten on the same terms.
|
// tracing.go:553) and marks it SourceURL, so it is rewritten on
|
||||||
if event.Transaction != "" {
|
// the same terms. Only a name nameSentryTransaction set from the
|
||||||
|
// route pattern, which it marks SourceRoute, is kept.
|
||||||
|
namedByRoute := event.TransactionInfo != nil &&
|
||||||
|
event.TransactionInfo.Source == sentry.SourceRoute
|
||||||
|
|
||||||
|
if event.Transaction != "" && !namedByRoute {
|
||||||
event.Transaction = sentryTransactionName(
|
event.Transaction = sentryTransactionName(
|
||||||
event.Transaction, pattern,
|
event.Transaction, pattern,
|
||||||
)
|
)
|
||||||
@@ -120,7 +126,8 @@ func scrubSentryRequest(
|
|||||||
// The transaction dispatch has no such request: Span.doFinish calls
|
// The transaction dispatch has no such request: Span.doFinish calls
|
||||||
// hub.CaptureEvent (tracing.go:356), which passes a nil hint that the
|
// hub.CaptureEvent (tracing.go:356), which passes a nil hint that the
|
||||||
// client replaces with an empty one (client.go:620-622). The pattern
|
// client replaces with an empty one (client.go:620-622). The pattern
|
||||||
// is therefore always "" there, and the callers fall back.
|
// is therefore always "" there, and the callers fall back. A routed
|
||||||
|
// transaction is named by nameSentryTransaction instead.
|
||||||
func sentryRoutePattern(hint *sentry.EventHint) string {
|
func sentryRoutePattern(hint *sentry.EventHint) string {
|
||||||
if hint == nil || hint.Context == nil {
|
if hint == nil || hint.Context == nil {
|
||||||
return ""
|
return ""
|
||||||
@@ -142,6 +149,52 @@ func sentryRoutePattern(hint *sentry.EventHint) string {
|
|||||||
return rctx.RoutePattern()
|
return rctx.RoutePattern()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// nameSentryTransaction renames the request's Sentry transaction to
|
||||||
|
// "METHOD <route pattern>", such as "POST /h/{uuid}". sentryhttp names
|
||||||
|
// it after the concrete path, which scrubSentryRequest withholds;
|
||||||
|
// since Sentry groups transactions by name, every transaction in the
|
||||||
|
// service would otherwise land in one group per method, such as
|
||||||
|
// "POST /(redacted)".
|
||||||
|
//
|
||||||
|
// It has to run inside the sentryhttp handler, where the transaction
|
||||||
|
// is on the request context. It renames on the way out, once chi has
|
||||||
|
// routed the request, and does so in a defer so that a request whose
|
||||||
|
// handler panics is named too. The context is taken when the defer is
|
||||||
|
// set up, before routing, but the chi routing context on it is a
|
||||||
|
// pointer that chi fills in as it routes.
|
||||||
|
func nameSentryTransaction(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer setSentryTransactionName(r.Context(), r.Method)
|
||||||
|
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// setSentryTransactionName does the rename for nameSentryTransaction.
|
||||||
|
// A path that matches nothing at the top level has no pattern; its
|
||||||
|
// name is left as the SDK set it, for scrubSentryRequest to withhold.
|
||||||
|
// Inside a route group, a path that matches no route in the group, or
|
||||||
|
// a request the group's own middleware turns away before routing, has
|
||||||
|
// the group's pattern, such as "/pages/*", and is named after it.
|
||||||
|
func setSentryTransactionName(ctx context.Context, method string) {
|
||||||
|
transaction := sentry.TransactionFromContext(ctx)
|
||||||
|
rctx := chi.RouteContext(ctx)
|
||||||
|
|
||||||
|
if transaction == nil || rctx == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern := rctx.RoutePattern()
|
||||||
|
if pattern == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction.Name = method + " " + pattern
|
||||||
|
transaction.Source = sentry.SourceRoute
|
||||||
|
}
|
||||||
|
|
||||||
// sentryRouteURL rebuilds an event's request URL with the route
|
// sentryRouteURL rebuilds an event's request URL with the route
|
||||||
// pattern in place of the concrete path.
|
// pattern in place of the concrete path.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"github.com/go-chi/chi"
|
"github.com/go-chi/chi"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
"sneak.berlin/go/webhooker/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -130,11 +131,15 @@ func (c sentryCase) capture(t *testing.T) []*sentry.Event {
|
|||||||
// router mirrors the one ordering these tests depend on, over the two
|
// router mirrors the one ordering these tests depend on, over the two
|
||||||
// route patterns they need: a recovering middleware outside, then the
|
// route patterns they need: a recovering middleware outside, then the
|
||||||
// sentryhttp handler registered with Use and Repanic set, exactly as
|
// sentryhttp handler registered with Use and Repanic set, exactly as
|
||||||
// routes.go orders the two. The bare recover stands in for
|
// routes.go orders the two. It leaves out nameSentryTransaction,
|
||||||
// Middleware.Recoverer, which holds that outer slot in production; it
|
// which routes.go installs inside the sentryhttp handler, so a
|
||||||
// is here only to keep panic stacks out of the test output. That the
|
// transaction captured here keeps the SDK's name and shows what the
|
||||||
// production one really does catch what sentryhttp re-raises is
|
// hook does with a transaction that missed the rename. The bare
|
||||||
// pinned separately, by TestSentryStillSeesAPanic.
|
// recover stands in for Middleware.Recoverer, which holds that outer
|
||||||
|
// slot in production; it is here only to keep panic stacks out of the
|
||||||
|
// test output. That the production one really does catch what
|
||||||
|
// sentryhttp re-raises is pinned separately, by
|
||||||
|
// TestSentryStillSeesAPanic.
|
||||||
func (c sentryCase) router() http.Handler {
|
func (c sentryCase) router() http.Handler {
|
||||||
handler := func(_ http.ResponseWriter, r *http.Request) {
|
handler := func(_ http.ResponseWriter, r *http.Request) {
|
||||||
// This call is what drains the body tee and fills the
|
// This call is what drains the body tee and fills the
|
||||||
@@ -353,8 +358,9 @@ func TestSentryScrub_KeepsTheRoutingContext(t *testing.T) {
|
|||||||
// Span.doFinish captures with a nil hint, so BeforeSendTransaction
|
// Span.doFinish captures with a nil hint, so BeforeSendTransaction
|
||||||
// gets one with no context and no request: the route pattern is out of
|
// gets one with no context and no request: the route pattern is out of
|
||||||
// reach and both the URL and the SDK-built transaction name have to
|
// reach and both the URL and the SDK-built transaction name have to
|
||||||
// fall back. Tracing is off in this service, so no transaction event
|
// fall back. This router has no nameSentryTransaction, so this is the
|
||||||
// is produced today; the hook is a floor against that changing.
|
// floor under a transaction that missed the rename;
|
||||||
|
// TestSentryTransaction_NamedByRoute covers the routes as they ship.
|
||||||
func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) {
|
func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -376,6 +382,83 @@ func TestSentryScrub_RedactsTheTransactionDispatch(t *testing.T) {
|
|||||||
assert.Equal(t, "POST /(redacted)", event.Transaction)
|
assert.Equal(t, "POST /(redacted)", event.Transaction)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestSentryTransaction_NamedByRoute serves requests through the real
|
||||||
|
// route tree with tracing on, which the service leaves off. A routed
|
||||||
|
// request's transaction is named after its route pattern, so Sentry
|
||||||
|
// can group transactions by route, and the entrypoint UUID in the
|
||||||
|
// receiver path appears nowhere in the event. A path that matches
|
||||||
|
// nothing at the top level has no pattern, and its name is still
|
||||||
|
// withheld; one that matches no route inside a route group is named
|
||||||
|
// after the group's pattern.
|
||||||
|
func TestSentryTransaction_NamedByRoute(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// newTestEnv leaves the receiver rate limit at zero, which
|
||||||
|
// refuses every request before it reaches the receiver.
|
||||||
|
env := newTestEnvWithConfig(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
ReceiverRateLimit: 10,
|
||||||
|
})
|
||||||
|
|
||||||
|
router := server.NewRouterWithSentryForTest(
|
||||||
|
t, env.log, env.cfg, env.mw, env.hnd,
|
||||||
|
)
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
method, path, want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
http.MethodPost,
|
||||||
|
"/h/" + sentryReceiverUUID,
|
||||||
|
"POST /h/{uuid}",
|
||||||
|
},
|
||||||
|
// An admin page route group, which installs its own
|
||||||
|
// sentryhttp handler.
|
||||||
|
{http.MethodGet, "/pages/login", "GET /pages/login"},
|
||||||
|
{http.MethodGet, "/pages/unrouted", "GET /pages/*"},
|
||||||
|
{
|
||||||
|
http.MethodPost,
|
||||||
|
"/h/" + sentryReceiverUUID + "/unrouted",
|
||||||
|
"POST /(redacted)",
|
||||||
|
},
|
||||||
|
} {
|
||||||
|
t.Run(tc.want, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
transport := &captureTransport{}
|
||||||
|
|
||||||
|
opts := server.SentryClientOptionsForTest(
|
||||||
|
"https://public@sentry.invalid/1",
|
||||||
|
"webhooker-test",
|
||||||
|
)
|
||||||
|
opts.Transport = transport
|
||||||
|
opts.EnableTracing = true
|
||||||
|
opts.TracesSampleRate = 1.0
|
||||||
|
|
||||||
|
client, err := sentry.NewClient(opts)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
sentry.SetHubOnContext(
|
||||||
|
context.Background(),
|
||||||
|
sentry.NewHub(client, sentry.NewScope()),
|
||||||
|
),
|
||||||
|
tc.method, tc.path, nil,
|
||||||
|
)
|
||||||
|
router.ServeHTTP(httptest.NewRecorder(), req)
|
||||||
|
|
||||||
|
event := onlyEvent(t, transport.events)
|
||||||
|
require.Equal(t, "transaction", event.Type)
|
||||||
|
|
||||||
|
assert.Equal(t, tc.want, event.Transaction)
|
||||||
|
assert.NotContains(
|
||||||
|
t, marshalEvent(t, event), sentryReceiverUUID,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook pins
|
// TestSentryScrub_TransactionDispatchIsUnscrubbedWithoutTheHook pins
|
||||||
// that dispatch's premise the same way, since it is the one the
|
// that dispatch's premise the same way, since it is the one the
|
||||||
// service does not exercise today.
|
// service does not exercise today.
|
||||||
|
|||||||
Reference in New Issue
Block a user