Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3eb77b96a2 | ||
|
|
c513816a55 | ||
|
|
2bb4683512 |
@@ -145,6 +145,11 @@ TTY detection, and security headers are always applied.
|
|||||||
| `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) |
|
||||||
|
|
||||||
|
The Settings page of the web UI (`/settings`, behind the login) lists
|
||||||
|
every one of these with the value the running server loaded. It is
|
||||||
|
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
||||||
|
set or not set, never their values.
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
|
|
||||||
By default every delivery target must resolve to a public address. The
|
By default every delivery target must resolve to a public address. The
|
||||||
@@ -158,19 +163,20 @@ WireServer, which serves an Azure VM its credentials. Because it is a
|
|||||||
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
public address, listing it in `ALLOWED_EGRESS_CIDRS` reopens it.
|
||||||
|
|
||||||
That is all the default blocklist covers: the IPv4 private and reserved
|
That is all the default blocklist covers: the IPv4 private and reserved
|
||||||
ranges; of IPv6, only loopback (`::1`), unique local addresses
|
ranges; of IPv6, only loopback (`::1`), the unspecified address (`::`),
|
||||||
(`fc00::/7`) and link-local addresses (`fe80::/10`); and certain public
|
unique local addresses (`fc00::/7`), link-local addresses (`fe80::/10`),
|
||||||
addresses. A public address belongs on the default blocklist only if it
|
multicast (`ff00::/8`) and documentation space (`2001:db8::/32`); and
|
||||||
hands credentials, user data or bootstrap material to whatever can reach
|
certain public addresses. A public address belongs on the default
|
||||||
it, without the caller presenting anything. A provider's other public
|
blocklist only if it hands credentials, user data or bootstrap material
|
||||||
addresses are not refused. IBM Cloud, for example, serves its package
|
to whatever can reach it, without the caller presenting anything. A
|
||||||
mirrors, time servers and object storage on `161.26.0.0/16`, and the
|
provider's other public addresses are not refused. IBM Cloud, for
|
||||||
private endpoints of its own cloud services on `166.8.0.0/14`. Neither
|
example, serves its package mirrors, time servers and object storage on
|
||||||
range hands out credentials that way: the token service among those
|
`161.26.0.0/16`, and the private endpoints of its own cloud services on
|
||||||
endpoints issues a token only in exchange for something the caller
|
`166.8.0.0/14`. Neither range hands out credentials that way: the token
|
||||||
presents, such as an API key. Reaching these services can be a
|
service among those endpoints issues a token only in exchange for
|
||||||
legitimate delivery, and every cloud has some, so a partial list would
|
something the caller presents, such as an API key. Reaching these
|
||||||
promise coverage it does not give.
|
services can be a legitimate delivery, and every cloud has some, so a
|
||||||
|
partial list would promise coverage it does not give.
|
||||||
|
|
||||||
That default is also inconvenient for the thing webhooker is mostly
|
That default is also inconvenient for the thing webhooker is mostly
|
||||||
for: taking a public webhook and forwarding it to something on your own
|
for: taking a public webhook and forwarding it to something on your own
|
||||||
@@ -210,16 +216,16 @@ Two things this setting cannot do:
|
|||||||
the list is always an allowlist; an empty list (the default) means
|
the list is always an allowlist; an empty list (the default) means
|
||||||
every private and reserved range stays refused. Note that
|
every private and reserved range stays refused. Note that
|
||||||
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
`0.0.0.0/0` gets you most of the way there anyway, per above.
|
||||||
- **It cannot open link-local, or a cloud metadata endpoint at a
|
- **It cannot open link-local, the unspecified addresses, or a cloud
|
||||||
non-public address that discloses credentials or user data.** An
|
metadata endpoint at a non-public address that discloses credentials
|
||||||
address is on the list below when it is not a public address and both
|
or user data.** A metadata address is on the list below when it is not
|
||||||
of these hold: the provider fixes it, so it cannot collide with
|
a public address and both of these hold: the provider fixes it, so it
|
||||||
anything you run; and reaching it hands out credentials, user data or
|
cannot collide with anything you run; and reaching it hands out
|
||||||
bootstrap material. Those stay blocked no matter what you list,
|
credentials, user data or bootstrap material. Those stay blocked no
|
||||||
including when you list them outright or list a supernet such as
|
matter what you list, including when you list them outright or list a
|
||||||
`0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`. Treat this as best
|
supernet such as `0.0.0.0/0`, `::/0`, `fd00::/8` or `100.64.0.0/10`.
|
||||||
effort rather than a guarantee — it is a hand-maintained list and the
|
Treat this as best effort rather than a guarantee — it is a
|
||||||
caveat below the table applies:
|
hand-maintained list and the caveat below the table applies:
|
||||||
|
|
||||||
| Blocked unconditionally | What it is |
|
| Blocked unconditionally | What it is |
|
||||||
| ----------------------- | ---------- |
|
| ----------------------- | ---------- |
|
||||||
@@ -233,14 +239,25 @@ Two things this setting cannot do:
|
|||||||
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
| `fd00:a9fe:a9fe::1/128` | Linode/Akamai metadata over IPv6 |
|
||||||
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
| `100.100.100.200/32` | Alibaba Cloud metadata, inside CGNAT |
|
||||||
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
| `192.0.0.192/32` | Oracle Cloud Classic metadata |
|
||||||
|
| `0.0.0.0/32` | IPv4 unspecified address, which reaches this host's loopback on Linux |
|
||||||
|
| `::/128` | IPv6 unspecified address, which reaches this host's loopback on Linux |
|
||||||
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
| `::a9fe:a9fe/128` | `169.254.169.254` as an IPv4-compatible IPv6 address |
|
||||||
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
| `64:ff9b::a9fe:a9fe/128` | `169.254.169.254` behind the NAT64 well-known prefix |
|
||||||
|
|
||||||
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
The IPv4-mapped form `::ffff:169.254.169.254` is covered by the
|
||||||
`169.254.0.0/16` entry. Reaching any of these is credential or
|
`169.254.0.0/16` entry. Reaching any of these but the two unspecified
|
||||||
user-data theft rather than delivery to an internal service. Every
|
addresses is credential or user-data theft rather than delivery to an
|
||||||
entry outside the two link-local blocks is a single address, so
|
internal service. Every entry outside the two link-local blocks is a
|
||||||
blocking it costs you nothing else on the network around it.
|
single address, so blocking it costs you nothing else on the network
|
||||||
|
around it.
|
||||||
|
|
||||||
|
The unspecified addresses `0.0.0.0` and `::` hand out nothing
|
||||||
|
themselves, but no host can have either, and on Linux a connection to
|
||||||
|
one reaches this host's own loopback. They are listed so that an
|
||||||
|
allowlist reaches loopback only through an entry that covers a loopback
|
||||||
|
address, such as `127.0.0.0/8`, `::1` or `0.0.0.0/0`, never through one
|
||||||
|
that covers only `0.0.0.0` or `::`; `0.0.0.0/8`, for example, does not
|
||||||
|
open loopback.
|
||||||
|
|
||||||
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
The six ULA entries, all inside `fd00::/8`, are why this matters in
|
||||||
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
practice: `fd00::/8` is an ordinary block to allowlist for your own
|
||||||
@@ -2780,6 +2797,7 @@ returns to the page that was asked for.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
|
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
||||||
| `GET` | `/hooks` | List user's webhooks |
|
| `GET` | `/hooks` | List user's webhooks |
|
||||||
| `GET` | `/hooks/new` | Create webhook form |
|
| `GET` | `/hooks/new` | Create webhook form |
|
||||||
| `POST` | `/hooks/new` | Create webhook submission |
|
| `POST` | `/hooks/new` | Create webhook submission |
|
||||||
@@ -2893,6 +2911,7 @@ webhooker/
|
|||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
|
│ │ ├── settings.go # Read-only Settings page handler
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
@@ -2979,10 +2998,10 @@ Applied to all routes in this order:
|
|||||||
2. **SecurityHeaders** — Production security headers on every response
|
2. **SecurityHeaders** — Production security headers on every response
|
||||||
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
(HSTS, X-Content-Type-Options, X-Frame-Options, CSP, Referrer-Policy,
|
||||||
Permissions-Policy)
|
Permissions-Policy)
|
||||||
3. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
3. **Logging** — Structured request logging (method, URL, status,
|
||||||
`METRICS_PASSWORD` are both set)
|
|
||||||
4. **Logging** — Structured request logging (method, URL, status,
|
|
||||||
latency, remote IP, user agent, request ID)
|
latency, remote IP, user agent, request ID)
|
||||||
|
4. **Metrics** — Prometheus HTTP metrics (if `METRICS_USERNAME` and
|
||||||
|
`METRICS_PASSWORD` are both set)
|
||||||
5. **CORS** — Cross-origin resource sharing headers
|
5. **CORS** — Cross-origin resource sharing headers
|
||||||
6. **Timeout** — 60-second request timeout
|
6. **Timeout** — 60-second request timeout
|
||||||
7. **Recoverer** — Panic recovery: one `ERROR` record through
|
7. **Recoverer** — Panic recovery: one `ERROR` record through
|
||||||
@@ -3002,18 +3021,14 @@ local record instead of nothing. What that placement gives up is
|
|||||||
recovery of a panic in the six entries above it, none of which does
|
recovery of a panic in the six entries above it, none of which does
|
||||||
more than set a header or start a timer.
|
more than set a header or start a timer.
|
||||||
|
|
||||||
Metrics sits outside Logging so that a handler's flush reaches the
|
Each admin page route group (`/pages`, `/user/*`, `/settings`, `/hooks`,
|
||||||
client: go-http-metrics' writer passes a flush on only when the writer
|
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is set, its
|
||||||
inside it has a `Flush` method, and the access log's writer has none.
|
own **Sentry** error reporting. That Recoverer answers a panic with the `500`
|
||||||
|
error page in the normal layout; the global one keeps the plain-text `500` for
|
||||||
|
every other route.
|
||||||
|
|
||||||
Each admin page route group (`/pages`, `/user/*`, `/hooks`,
|
Additionally, form endpoints (`/pages`, `/user/*`, `/settings`,
|
||||||
`/hook/*`) starts with its own **Recoverer** and, if `SENTRY_DSN` is
|
`/hooks`, `/hook/*`) apply a **MaxBodySize** middleware that limits
|
||||||
set, its own **Sentry** error reporting. That Recoverer answers a panic
|
|
||||||
with the `500` error page in the normal layout; the global one keeps
|
|
||||||
the plain-text `500` for every other route.
|
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/hooks`,
|
|
||||||
`/hook/*`) apply a **MaxBodySize** middleware that limits
|
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
||||||
CSRF middleware in every one of those route groups, because
|
CSRF middleware in every one of those route groups, because
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
gorilla/csrf parses the form; if the cap were installed after it, form
|
||||||
@@ -3032,7 +3047,7 @@ declared length. A chunked request, or
|
|||||||
one that lies about its length, is hard-capped by
|
one that lies about its length, is hard-capped by
|
||||||
`http.MaxBytesReader` and fails downstream at form-parse time.
|
`http.MaxBytesReader` and fails downstream at form-parse time.
|
||||||
|
|
||||||
Those same four route groups then apply **CSRF** and **NoCache**
|
Those same five route groups then apply **CSRF** and **NoCache**
|
||||||
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
(`Cache-Control: no-store`, `Pragma: no-cache`), and every group except
|
||||||
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
`/pages` applies **RequireAuth**. The rate limiters are per-route
|
||||||
rather than global: **PasswordChangeRateLimit** on
|
rather than global: **PasswordChangeRateLimit** on
|
||||||
@@ -3078,12 +3093,12 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
by middleware that runs before CSRF parses the form
|
by middleware that runs before CSRF parses the form
|
||||||
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
- **CSRF protection** via [gorilla/csrf](https://github.com/gorilla/csrf)
|
||||||
on all state-changing forms (cookie-based double-submit tokens with
|
on all state-changing forms (cookie-based double-submit tokens with
|
||||||
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`, and
|
HMAC authentication). Applied to `/pages`, `/hooks`, `/hook`,
|
||||||
`/user` routes. Excluded from `/h` (inbound webhook POSTs) and
|
`/settings`, and `/user` routes. Excluded from `/h` (inbound webhook
|
||||||
`/api` (stateless API). The middleware detects TLS per-request through
|
POSTs) and `/api` (stateless API). The middleware detects TLS
|
||||||
`internal/reqtls.IsTLS` — the same predicate the session cookie uses —
|
per-request through `internal/reqtls.IsTLS` — the same predicate the
|
||||||
to set appropriate cookie security flags and Origin/Referer validation
|
session cookie uses — to set appropriate cookie security flags and
|
||||||
mode
|
Origin/Referer validation mode
|
||||||
- **The entrypoint URL is the receiver's only credential.** Nothing
|
- **The entrypoint URL is the receiver's only credential.** Nothing
|
||||||
about an inbound request is verified; possession of the UUID
|
about an inbound request is verified; possession of the UUID
|
||||||
authorises submission, and no shared secret or signature check will
|
authorises submission, and no shared secret or signature check will
|
||||||
@@ -3097,7 +3112,8 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
route through a single decision function, so they cannot disagree
|
route through a single decision function, so they cannot disagree
|
||||||
about a destination. An operator can permit specific blocks with
|
about a destination. An operator can permit specific blocks with
|
||||||
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
[`ALLOWED_EGRESS_CIDRS`](#allowing-egress-to-your-own-network); the
|
||||||
guard cannot be switched off, and link-local plus a
|
guard cannot be switched off, and link-local, the unspecified
|
||||||
|
addresses `0.0.0.0` and `::`, and a
|
||||||
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
[pinned set](#allowing-egress-to-your-own-network) of known cloud
|
||||||
metadata endpoints — several of which are ULAs outside link-local —
|
metadata endpoints — several of which are ULAs outside link-local —
|
||||||
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
stay blocked whatever is listed, though listing `0.0.0.0/0` or
|
||||||
|
|||||||
@@ -196,12 +196,13 @@ type Config struct {
|
|||||||
// otherwise refuse. The guard itself is always on: there is no
|
// otherwise refuse. The guard itself is always on: there is no
|
||||||
// setting that disables SSRF protection, and delivery's
|
// setting that disables SSRF protection, and delivery's
|
||||||
// alwaysBlockedNetworks stays blocked no matter what is listed
|
// alwaysBlockedNetworks stays blocked no matter what is listed
|
||||||
// here. That set is link-local plus the cloud metadata
|
// here. That set is link-local, the unspecified addresses
|
||||||
// endpoints outside it that disclose credentials or user data
|
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
|
||||||
// at a provider-fixed, non-public address; it is not
|
// link-local that disclose credentials or user data at a
|
||||||
// exhaustive of every cloud's metadata address. See
|
// provider-fixed, non-public address; it is not exhaustive of
|
||||||
// alwaysBlockedNetworks for the authoritative list and the
|
// every cloud's metadata address. See
|
||||||
// criterion it is built from.
|
// alwaysBlockedNetworks for the authoritative list and why
|
||||||
|
// each entry is on it.
|
||||||
AllowedEgressCIDRs []netip.Prefix
|
AllowedEgressCIDRs []netip.Prefix
|
||||||
|
|
||||||
params *ConfigParams
|
params *ConfigParams
|
||||||
|
|||||||
@@ -124,6 +124,11 @@ func testEnvironmentConfigSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||||
|
// running after a start or stop timeout would write there after
|
||||||
|
// the test has returned. The same holds for every fxtest.New
|
||||||
|
// below.
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -272,6 +277,7 @@ func testRetentionSweepIntervalSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -364,6 +370,7 @@ func testSessionIdleTimeoutSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -404,6 +411,7 @@ func TestDefaultDataDir(t *testing.T) {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -534,6 +542,7 @@ func testReceiverRateLimitSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -650,6 +659,7 @@ func testTrustedProxiesSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -763,6 +773,7 @@ func testAllowedEgressCIDRsSuccess(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
@@ -1006,6 +1017,7 @@ func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(globals.New, logger.New, config.New),
|
fx.Provide(globals.New, logger.New, config.New),
|
||||||
fx.Populate(&cfg),
|
fx.Populate(&cfg),
|
||||||
)
|
)
|
||||||
|
|||||||
+57
-13
@@ -37,8 +37,8 @@ var (
|
|||||||
"blocked cloud metadata address",
|
"blocked cloud metadata address",
|
||||||
)
|
)
|
||||||
errBlockedMetadata = errors.New(
|
errBlockedMetadata = errors.New(
|
||||||
"blocked link-local or cloud instance metadata " +
|
"blocked link-local, cloud instance metadata or " +
|
||||||
"address: ALLOWED_EGRESS_CIDRS cannot open it",
|
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
|
||||||
)
|
)
|
||||||
errInvalidScheme = errors.New(
|
errInvalidScheme = errors.New(
|
||||||
"only http and https are allowed",
|
"only http and https are allowed",
|
||||||
@@ -72,14 +72,17 @@ var blockedNetworks []*net.IPNet
|
|||||||
var blockedPublicNetworks []*net.IPNet
|
var blockedPublicNetworks []*net.IPNet
|
||||||
|
|
||||||
// alwaysBlockedNetworks are the ranges no configuration can
|
// alwaysBlockedNetworks are the ranges no configuration can
|
||||||
// open: the link-local blocks and the cloud instance metadata
|
// open, so a supplied CIDR that covers one still leaves it
|
||||||
// endpoints that live outside them. Reaching one is credential
|
// blocked. An entry is here for one of two reasons: it is a
|
||||||
// or user-data theft rather than delivery to an internal
|
// metadata endpoint (the link-local blocks and the cloud
|
||||||
// service, so a supplied CIDR that covers such an address still
|
// instance metadata endpoints that live outside them), or it is
|
||||||
// leaves it blocked.
|
// an unspecified address. Reaching a metadata endpoint is
|
||||||
|
// credential or user-data theft rather than delivery to an
|
||||||
|
// internal service.
|
||||||
//
|
//
|
||||||
// Inclusion criterion — an address belongs here only if BOTH
|
// Inclusion criterion for metadata endpoints — one belongs here
|
||||||
// hold, and every entry below satisfies both:
|
// only if BOTH hold, and every metadata entry below satisfies
|
||||||
|
// both:
|
||||||
//
|
//
|
||||||
// 1. It is a fixed address assigned by the provider, or a
|
// 1. It is a fixed address assigned by the provider, or a
|
||||||
// range reserved by IANA — never one the operator chose.
|
// range reserved by IANA — never one the operator chose.
|
||||||
@@ -90,8 +93,8 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// not cheaply rotated.
|
// not cheaply rotated.
|
||||||
//
|
//
|
||||||
// Both halves are load-bearing, so use them to refuse a
|
// Both halves are load-bearing, so use them to refuse a
|
||||||
// candidate and say why. An endpoint disclosing only the
|
// metadata candidate and say why. An endpoint disclosing only
|
||||||
// operator's own inventory (instance id, region, disks, NICs)
|
// the operator's own inventory (instance id, region, disks, NICs)
|
||||||
// fails (2): letting a delivery target reach the operator's own
|
// fails (2): letting a delivery target reach the operator's own
|
||||||
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
|
||||||
// provide. But (2) is not "IAM credentials only" either —
|
// provide. But (2) is not "IAM credentials only" either —
|
||||||
@@ -112,6 +115,15 @@ var blockedPublicNetworks []*net.IPNet
|
|||||||
// This is a criterion, not an enumeration of every metadata
|
// This is a criterion, not an enumeration of every metadata
|
||||||
// address in existence.
|
// address in existence.
|
||||||
//
|
//
|
||||||
|
// The unspecified addresses 0.0.0.0 and :: are here for a
|
||||||
|
// separate reason: they disclose nothing, but no host can have
|
||||||
|
// either, and on Linux a connection to one reaches this host's
|
||||||
|
// own loopback. Listing them means an allowlist reaches loopback
|
||||||
|
// only through an entry that covers a loopback address
|
||||||
|
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
|
||||||
|
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
|
||||||
|
// else lives at either address, so refusing them costs nothing.
|
||||||
|
//
|
||||||
// Every entry is either already in blockedNetworks — this list is
|
// Every entry is either already in blockedNetworks — this list is
|
||||||
// what makes it unconditional — or an alternate encoding of
|
// what makes it unconditional — or an alternate encoding of
|
||||||
// 169.254.169.254 that Contains does not match against
|
// 169.254.169.254 that Contains does not match against
|
||||||
@@ -131,23 +143,46 @@ var alwaysBlockedNetworks []*net.IPNet
|
|||||||
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
|
||||||
func init() {
|
func init() {
|
||||||
blockedNetworks = mustParseCIDRs([]string{
|
blockedNetworks = mustParseCIDRs([]string{
|
||||||
|
// IPv4 loopback.
|
||||||
"127.0.0.0/8",
|
"127.0.0.0/8",
|
||||||
|
// RFC 1918 private network.
|
||||||
"10.0.0.0/8",
|
"10.0.0.0/8",
|
||||||
|
// RFC 1918 private network.
|
||||||
"172.16.0.0/12",
|
"172.16.0.0/12",
|
||||||
|
// RFC 1918 private network.
|
||||||
"192.168.0.0/16",
|
"192.168.0.0/16",
|
||||||
|
// IPv4 link-local.
|
||||||
"169.254.0.0/16",
|
"169.254.0.0/16",
|
||||||
|
// "This network", holding the IPv4 unspecified address 0.0.0.0.
|
||||||
"0.0.0.0/8",
|
"0.0.0.0/8",
|
||||||
|
// Carrier-grade NAT shared address space.
|
||||||
"100.64.0.0/10",
|
"100.64.0.0/10",
|
||||||
|
// IETF protocol assignments.
|
||||||
"192.0.0.0/24",
|
"192.0.0.0/24",
|
||||||
|
// IPv4 documentation (TEST-NET-1).
|
||||||
"192.0.2.0/24",
|
"192.0.2.0/24",
|
||||||
|
// Benchmarking.
|
||||||
"198.18.0.0/15",
|
"198.18.0.0/15",
|
||||||
|
// IPv4 documentation (TEST-NET-2).
|
||||||
"198.51.100.0/24",
|
"198.51.100.0/24",
|
||||||
|
// IPv4 documentation (TEST-NET-3).
|
||||||
"203.0.113.0/24",
|
"203.0.113.0/24",
|
||||||
|
// IPv4 multicast.
|
||||||
"224.0.0.0/4",
|
"224.0.0.0/4",
|
||||||
|
// Reserved, including the broadcast address.
|
||||||
"240.0.0.0/4",
|
"240.0.0.0/4",
|
||||||
|
// IPv6 loopback.
|
||||||
"::1/128",
|
"::1/128",
|
||||||
|
// IPv6 unspecified address.
|
||||||
|
"::/128",
|
||||||
|
// IPv6 unique local addresses.
|
||||||
"fc00::/7",
|
"fc00::/7",
|
||||||
|
// IPv6 link-local.
|
||||||
"fe80::/10",
|
"fe80::/10",
|
||||||
|
// IPv6 multicast.
|
||||||
|
"ff00::/8",
|
||||||
|
// IPv6 documentation.
|
||||||
|
"2001:db8::/32",
|
||||||
})
|
})
|
||||||
|
|
||||||
blockedPublicNetworks = mustParseCIDRs([]string{
|
blockedPublicNetworks = mustParseCIDRs([]string{
|
||||||
@@ -207,6 +242,14 @@ func init() {
|
|||||||
// allowlist from opening it.
|
// allowlist from opening it.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
|
|
||||||
|
// The unspecified addresses, each of which reaches this
|
||||||
|
// host's loopback on Linux.
|
||||||
|
//
|
||||||
|
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
|
||||||
|
"0.0.0.0/32",
|
||||||
|
// IPv6 unspecified address.
|
||||||
|
"::/128",
|
||||||
|
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -343,8 +386,9 @@ func (g *Guard) allows(ip net.IP) bool {
|
|||||||
// The order is the policy:
|
// The order is the policy:
|
||||||
//
|
//
|
||||||
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
// 1. alwaysBlockedNetworks is refused before the allowlist is
|
||||||
// consulted, so no configured CIDR reaches link-local or a
|
// consulted, so no configured CIDR reaches link-local, a
|
||||||
// cloud metadata endpoint at a non-public address.
|
// cloud metadata endpoint at a non-public address, or an
|
||||||
|
// unspecified address.
|
||||||
// 2. The allowlist is consulted next, so a listed private
|
// 2. The allowlist is consulted next, so a listed private
|
||||||
// network, or a listed public address on the default
|
// network, or a listed public address on the default
|
||||||
// blocklist, becomes reachable.
|
// blocklist, becomes reachable.
|
||||||
|
|||||||
@@ -168,12 +168,13 @@ func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
|
|||||||
|
|
||||||
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
|
||||||
// case: cloud instance metadata endpoints are credential theft
|
// case: cloud instance metadata endpoints are credential theft
|
||||||
// rather than delivery to an internal service, so no allowlist
|
// rather than delivery to an internal service, and the
|
||||||
// reaches one. Every guard below names a CIDR that covers its
|
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
|
||||||
// target — including 0.0.0.0/0, ::/0, and the ordinary ULA and
|
// on Linux, so no allowlist reaches any of them. Every guard
|
||||||
// CGNAT blocks an operator would really list — and the address
|
// below names a CIDR that covers its target — including
|
||||||
// must stay refused anyway, on both the validation and the
|
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
|
||||||
// delivery path.
|
// operator would really list — and the address must stay
|
||||||
|
// refused anyway, on both the validation and the delivery path.
|
||||||
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -219,15 +220,17 @@ type metadataAlwaysRefusedCase struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// metadataAlwaysRefusedCases enumerates every unconditionally
|
// metadataAlwaysRefusedCases enumerates every unconditionally
|
||||||
// blocked address together with an allowlist entry that would
|
// blocked address (link-local, the cloud metadata endpoints and
|
||||||
// otherwise reach it. Split by family of address only to stay
|
// the unspecified addresses) together with an allowlist entry
|
||||||
// under the function-length limit.
|
// that would otherwise reach it. Split by family of address only
|
||||||
|
// to stay under the function-length limit.
|
||||||
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
cases := linkLocalRefusedCases()
|
cases := linkLocalRefusedCases()
|
||||||
cases = append(cases, ulaMetadataRefusedCases()...)
|
cases = append(cases, ulaMetadataRefusedCases()...)
|
||||||
cases = append(cases, ipv4MetadataRefusedCases()...)
|
cases = append(cases, ipv4MetadataRefusedCases()...)
|
||||||
|
cases = append(cases, encodedMetadataRefusedCases()...)
|
||||||
|
|
||||||
return append(cases, encodedMetadataRefusedCases()...)
|
return append(cases, unspecifiedRefusedCases()...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// linkLocalRefusedCases covers the link-local blocks, including
|
// linkLocalRefusedCases covers the link-local blocks, including
|
||||||
@@ -367,6 +370,23 @@ func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// unspecifiedRefusedCases covers the unspecified addresses, each
|
||||||
|
// of which reaches this host's loopback on Linux.
|
||||||
|
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
|
||||||
|
return []metadataAlwaysRefusedCase{
|
||||||
|
{
|
||||||
|
name: "IPv4 unspecified address under 0.0.0.0/0",
|
||||||
|
allow: allowAllIPv4,
|
||||||
|
target: "http://0.0.0.0:8080/hook",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "IPv6 unspecified address under ::/0",
|
||||||
|
allow: allowAllIPv6,
|
||||||
|
target: "http://[::]:8080/hook",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
|
||||||
// not narrow anything: public addresses were reachable before it
|
// not narrow anything: public addresses were reachable before it
|
||||||
// existed and stay reachable, whether or not a list is set.
|
// existed and stay reachable, whether or not a list is set.
|
||||||
@@ -524,6 +544,10 @@ func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
|
|||||||
// Oracle Cloud Classic metadata, inside the blocked
|
// Oracle Cloud Classic metadata, inside the blocked
|
||||||
// 192.0.0.0/24.
|
// 192.0.0.0/24.
|
||||||
"192.0.0.192/32",
|
"192.0.0.192/32",
|
||||||
|
// The IPv4 and IPv6 unspecified addresses, each of
|
||||||
|
// which reaches this host's loopback on Linux.
|
||||||
|
"0.0.0.0/32",
|
||||||
|
"::/128",
|
||||||
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
// 169.254.169.254 as an IPv4-compatible IPv6 address.
|
||||||
"::a9fe:a9fe/128",
|
"::a9fe:a9fe/128",
|
||||||
// 169.254.169.254 behind the NAT64 well-known prefix.
|
// 169.254.169.254 behind the NAT64 well-known prefix.
|
||||||
@@ -556,7 +580,8 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
|||||||
{cidr: "172.16.0.0/12", reopenable: true},
|
{cidr: "172.16.0.0/12", reopenable: true},
|
||||||
{cidr: "192.168.0.0/16", reopenable: true},
|
{cidr: "192.168.0.0/16", reopenable: true},
|
||||||
{cidr: linkLocalIPv4, reopenable: false},
|
{cidr: linkLocalIPv4, reopenable: false},
|
||||||
{cidr: "0.0.0.0/8", reopenable: true},
|
// Its first address, 0.0.0.0, is in the unconditional set.
|
||||||
|
{cidr: "0.0.0.0/8", reopenable: false},
|
||||||
{cidr: "100.64.0.0/10", reopenable: true},
|
{cidr: "100.64.0.0/10", reopenable: true},
|
||||||
{cidr: "192.0.0.0/24", reopenable: true},
|
{cidr: "192.0.0.0/24", reopenable: true},
|
||||||
{cidr: "192.0.2.0/24", reopenable: true},
|
{cidr: "192.0.2.0/24", reopenable: true},
|
||||||
@@ -566,8 +591,11 @@ func TestDefaultBlocklist_PinnedSet(t *testing.T) {
|
|||||||
{cidr: "224.0.0.0/4", reopenable: true},
|
{cidr: "224.0.0.0/4", reopenable: true},
|
||||||
{cidr: "240.0.0.0/4", reopenable: true},
|
{cidr: "240.0.0.0/4", reopenable: true},
|
||||||
{cidr: "::1/128", reopenable: true},
|
{cidr: "::1/128", reopenable: true},
|
||||||
|
{cidr: "::/128", reopenable: false},
|
||||||
{cidr: "fc00::/7", reopenable: true},
|
{cidr: "fc00::/7", reopenable: true},
|
||||||
{cidr: "fe80::/10", reopenable: false},
|
{cidr: "fe80::/10", reopenable: false},
|
||||||
|
{cidr: "ff00::/8", reopenable: true},
|
||||||
|
{cidr: "2001:db8::/32", reopenable: true},
|
||||||
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
{cidr: "168.63.129.16/32", public: true, reopenable: true},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,42 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
|
||||||
|
// covers the unspecified addresses and the IPv6 multicast and
|
||||||
|
// documentation ranges: with no allowlist set, each is refused
|
||||||
|
// both when a target is created and when a delivery dials it.
|
||||||
|
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
|
||||||
|
t *testing.T,
|
||||||
|
) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
guard := delivery.NewTestGuard()
|
||||||
|
|
||||||
|
targets := []string{
|
||||||
|
// The unspecified addresses. On Linux a connection to
|
||||||
|
// either reaches this host's loopback.
|
||||||
|
"http://0.0.0.0:8080/hook",
|
||||||
|
"http://[::]:8080/hook",
|
||||||
|
// IPv6 multicast, all nodes.
|
||||||
|
"http://[ff02::1]/hook",
|
||||||
|
// IPv6 documentation.
|
||||||
|
"http://[2001:db8::1]/hook",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, target := range targets {
|
||||||
|
t.Run(target, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
require.Error(t,
|
||||||
|
guard.ValidateTargetURL(context.Background(), target),
|
||||||
|
"%s must be refused at target creation", target,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertDialRefused(t, guard, target)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestValidateTargetURL_Allowed(t *testing.T) {
|
func TestValidateTargetURL_Allowed(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -137,6 +137,10 @@ func bootAtDebug(t *testing.T, dataDir string) string {
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||||
|
// running after a start or stop timeout would write there after
|
||||||
|
// the test has returned.
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import (
|
|||||||
|
|
||||||
"github.com/prometheus/client_golang/prometheus"
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -57,6 +58,7 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -140,6 +142,7 @@ func New(
|
|||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
|
"settings.html": parsePageTemplate("settings.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
||||||
|
|||||||
@@ -84,16 +84,29 @@ func newTestApp(
|
|||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestAppWithConfig(
|
||||||
|
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
||||||
|
func newTestAppWithConfig(
|
||||||
|
t *testing.T,
|
||||||
|
cfg *config.Config,
|
||||||
|
targets ...any,
|
||||||
|
) *fxtest.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
return fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||||
|
// running after a start or stop timeout would write there after
|
||||||
|
// the test has returned.
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
func() *config.Config {
|
func() *config.Config { return cfg },
|
||||||
return &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
|||||||
@@ -0,0 +1,135 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notSet is what the Settings page shows for a value that is empty.
|
||||||
|
const notSet = "not set"
|
||||||
|
|
||||||
|
// settingRow is one line of the Settings page: an environment
|
||||||
|
// variable, what it controls, and the value the server loaded for it.
|
||||||
|
type settingRow struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSettings returns a handler for the read-only Settings page,
|
||||||
|
// which lists the configuration the server started with.
|
||||||
|
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderTemplate(w, r, "settings.html", map[string]any{
|
||||||
|
"Settings": settingRows(h.params.Config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingRows lists every field of cfg under the environment variable
|
||||||
|
// it is read from, with the description the README's configuration
|
||||||
|
// table gives it (less its pointers to other README sections), in the
|
||||||
|
// table's order. METRICS_PASSWORD and SENTRY_DSN are credentials, so
|
||||||
|
// their values never reach the page: only whether they are set.
|
||||||
|
func settingRows(cfg *config.Config) []settingRow {
|
||||||
|
metricsUsername := cfg.MetricsUsername
|
||||||
|
if metricsUsername == "" {
|
||||||
|
metricsUsername = notSet
|
||||||
|
}
|
||||||
|
|
||||||
|
return []settingRow{
|
||||||
|
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
||||||
|
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
||||||
|
{
|
||||||
|
"BIND_ADDRESS",
|
||||||
|
"IP address the HTTP listener binds. Loopback by default, " +
|
||||||
|
"so the cleartext listener is not published on every " +
|
||||||
|
"interface. The Docker image ships 0.0.0.0 instead",
|
||||||
|
cfg.BindAddress,
|
||||||
|
},
|
||||||
|
{"DATA_DIR", "Directory for all SQLite databases", cfg.DataDir},
|
||||||
|
{"DEBUG", "Enable debug logging", strconv.FormatBool(cfg.Debug)},
|
||||||
|
{
|
||||||
|
"MAINTENANCE_MODE",
|
||||||
|
"Report maintenanceMode: true in the healthcheck JSON. " +
|
||||||
|
"It does not change how any request is served — no " +
|
||||||
|
"maintenance page exists",
|
||||||
|
strconv.FormatBool(cfg.MaintenanceMode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"METRICS_USERNAME",
|
||||||
|
"Basic auth username for /metrics. Must be set together " +
|
||||||
|
"with METRICS_PASSWORD; one without the other fails " +
|
||||||
|
"startup",
|
||||||
|
metricsUsername,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"METRICS_PASSWORD",
|
||||||
|
"Basic auth password for /metrics. Must be set together " +
|
||||||
|
"with METRICS_USERNAME; one without the other fails " +
|
||||||
|
"startup",
|
||||||
|
setOrNotSet(cfg.MetricsPassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
setOrNotSet(cfg.SentryDSN),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
"How often the retention reaper and archive sweeper run " +
|
||||||
|
"(Go duration, must be positive)",
|
||||||
|
cfg.RetentionSweepInterval.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SESSION_IDLE_TIMEOUT",
|
||||||
|
"Idle session timeout (Go duration)",
|
||||||
|
cfg.SessionIdleTimeout.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
"Receiver requests/minute per IP per entrypoint " +
|
||||||
|
"(10x that per IP across the route)",
|
||||||
|
strconv.Itoa(cfg.ReceiverRateLimit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
cidrList(cfg.TrustedProxies),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ALLOWED_EGRESS_CIDRS",
|
||||||
|
"CIDRs that delivery targets may reach despite the " +
|
||||||
|
"SSRF blocklist",
|
||||||
|
cidrList(cfg.AllowedEgressCIDRs),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOrNotSet is how the Settings page shows a credential: whether it
|
||||||
|
// has a value, never the value itself.
|
||||||
|
func setOrNotSet(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return notSet
|
||||||
|
}
|
||||||
|
|
||||||
|
return "set"
|
||||||
|
}
|
||||||
|
|
||||||
|
// cidrList renders a CIDR list setting for the Settings page.
|
||||||
|
func cidrList(prefixes []netip.Prefix) string {
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsShown renders the Settings page over cfg as a logged-in user
|
||||||
|
// and returns the value it shows for each variable name, plus the
|
||||||
|
// whole page.
|
||||||
|
func settingsShown(
|
||||||
|
t *testing.T, cfg *config.Config,
|
||||||
|
) (map[string]string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/settings", nil,
|
||||||
|
)
|
||||||
|
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSettings().ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
row := regexp.MustCompile(
|
||||||
|
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
shown := map[string]string{}
|
||||||
|
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
||||||
|
shown[match[1]] = html.UnescapeString(match[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
return shown, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
// DEBUG and MAINTENANCE_MODE get opposite values, and each of
|
||||||
|
// METRICS_USERNAME, METRICS_PASSWORD and SENTRY_DSN is the only one
|
||||||
|
// of the three set in one of the content tests, so each row is
|
||||||
|
// checked against its own field.
|
||||||
|
cfg := &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Debug: true,
|
||||||
|
MaintenanceMode: false,
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
MetricsUsername: "scraper",
|
||||||
|
MetricsPassword: "",
|
||||||
|
Port: 9123,
|
||||||
|
SentryDSN: "",
|
||||||
|
BindAddress: "192.0.2.10",
|
||||||
|
RetentionSweepInterval: 17 * time.Minute,
|
||||||
|
SessionIdleTimeout: 3 * time.Hour,
|
||||||
|
ReceiverRateLimit: 77,
|
||||||
|
TrustedProxies: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.1.0.0/16"),
|
||||||
|
},
|
||||||
|
AllowedEgressCIDRs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.5.0/24"),
|
||||||
|
netip.MustParsePrefix("fd00::/8"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"WEBHOOKER_ENVIRONMENT": "dev",
|
||||||
|
"PORT": "9123",
|
||||||
|
"BIND_ADDRESS": "192.0.2.10",
|
||||||
|
"DATA_DIR": cfg.DataDir,
|
||||||
|
"DEBUG": "true",
|
||||||
|
"MAINTENANCE_MODE": "false",
|
||||||
|
"METRICS_USERNAME": "scraper",
|
||||||
|
"METRICS_PASSWORD": "not set",
|
||||||
|
"SENTRY_DSN": "not set",
|
||||||
|
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||||
|
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||||
|
"RECEIVER_RATE_LIMIT": "77",
|
||||||
|
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||||
|
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||||
|
}, shown)
|
||||||
|
|
||||||
|
assert.Contains(
|
||||||
|
t, body, `href="/settings"`,
|
||||||
|
"the navigation bar links to the page",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const metricsPassword = "metrics-password-1f9a"
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
MetricsPassword: metricsPassword,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||||
|
assert.Equal(t, "set", shown["METRICS_PASSWORD"])
|
||||||
|
assert.Equal(t, "not set", shown["SENTRY_DSN"])
|
||||||
|
assert.NotContains(t, body, metricsPassword)
|
||||||
|
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
||||||
|
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsSentryDSNOnlyAsSet(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
sentryKey = "dsnkey7c2e"
|
||||||
|
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
||||||
|
)
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
SentryDSN: sentryDSN,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
||||||
|
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
||||||
|
assert.NotContains(t, body, sentryKey)
|
||||||
|
}
|
||||||
@@ -1560,10 +1560,11 @@ func (h *Handlers) validateTargetURL(
|
|||||||
msg := "Invalid target URL: " + err.Error()
|
msg := "Invalid target URL: " + err.Error()
|
||||||
|
|
||||||
// Only a private or reserved address's refusal says how
|
// Only a private or reserved address's refusal says how
|
||||||
// to allow it. Metadata refusals never do: link-local and
|
// to allow it. Other refusals never do: link-local, the
|
||||||
// the other unconditional metadata addresses cannot be
|
// unspecified addresses and the unconditional metadata
|
||||||
// opened, and the default blocklist's public addresses,
|
// addresses cannot be opened, and the default
|
||||||
// which listing does open, hand out credentials.
|
// blocklist's public addresses, which listing does open,
|
||||||
|
// hand out credentials.
|
||||||
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
if errors.Is(err, delivery.ErrBlockedPrivateOrReservedIP) {
|
||||||
msg += ". Private and reserved addresses are refused " +
|
msg += ". Private and reserved addresses are refused " +
|
||||||
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
"by default; the server's ALLOWED_EGRESS_CIDRS " +
|
||||||
|
|||||||
@@ -233,13 +233,6 @@ func (lrw *loggingResponseWriter) WriteHeader(code int) {
|
|||||||
lrw.ResponseWriter.WriteHeader(code)
|
lrw.ResponseWriter.WriteHeader(code)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unwrap lets http.ResponseController reach the writer underneath, so
|
|
||||||
// a handler can still flush or set a write deadline through the access
|
|
||||||
// log.
|
|
||||||
func (lrw *loggingResponseWriter) Unwrap() http.ResponseWriter {
|
|
||||||
return lrw.ResponseWriter
|
|
||||||
}
|
|
||||||
|
|
||||||
// concreteLogURL renders the request's own URL for the access log
|
// concreteLogURL renders the request's own URL for the access log
|
||||||
// branches that keep it, with the query string replaced by a fixed
|
// branches that keep it, with the query string replaced by a fixed
|
||||||
// marker.
|
// marker.
|
||||||
|
|||||||
@@ -627,9 +627,11 @@ func TestRecovererIgnoresANonPanickingHandler(t *testing.T) {
|
|||||||
// net/http's own writer from http.ResponseController, so a handler
|
// net/http's own writer from http.ResponseController, so a handler
|
||||||
// that flushes or sets a deadline starts failing.
|
// that flushes or sets a deadline starts failing.
|
||||||
//
|
//
|
||||||
// The recoverer is the only middleware in the chain here;
|
// The recoverer is the only middleware in the chain here. The access
|
||||||
// TestFlushThroughProductionRouter in internal/server covers the
|
// logger's own wrapper does not implement Unwrap, so a chain
|
||||||
// shipped chain.
|
// containing it fails this regardless of what the recoverer does;
|
||||||
|
// what is being pinned is that the recoverer adds no such opacity of
|
||||||
|
// its own.
|
||||||
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
func TestRecovererKeepsResponseControllerWorking(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,10 @@ func newServerApp(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||||
|
// running after a start or stop timeout would write there after
|
||||||
|
// the test has returned.
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestFlushThroughProductionRouter drives http.ResponseController.Flush
|
|
||||||
// through the shipped router and checks that the flush reaches the
|
|
||||||
// writer the server handed in.
|
|
||||||
//
|
|
||||||
// Every middleware that wraps the writer has to pass a flush through,
|
|
||||||
// with an Unwrap method or a Flush of its own. One that does neither
|
|
||||||
// makes Flush return http.ErrNotSupported, or do nothing at all when
|
|
||||||
// the wrapper outside it only looks for a Flush method, and the
|
|
||||||
// handler that trips over it is far from the cause.
|
|
||||||
//
|
|
||||||
// It runs once with the defaults and once with metrics and Sentry on,
|
|
||||||
// because those two add middleware to the chain, and through both the
|
|
||||||
// global middleware and an admin page route group, which adds its own.
|
|
||||||
func TestFlushThroughProductionRouter(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
cases := []struct {
|
|
||||||
name string
|
|
||||||
cfg func(t *testing.T) *config.Config
|
|
||||||
sentryEnabled bool
|
|
||||||
}{
|
|
||||||
{
|
|
||||||
name: "defaults",
|
|
||||||
cfg: func(t *testing.T) *config.Config {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "metrics and Sentry on",
|
|
||||||
cfg: func(t *testing.T) *config.Config {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
return metricsConfig(t, metricsUser, metricsAuthValue)
|
|
||||||
},
|
|
||||||
sentryEnabled: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tc := range cases {
|
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnvWithConfig(t, tc.cfg(t))
|
|
||||||
|
|
||||||
var flushErr error
|
|
||||||
|
|
||||||
probe := func(w http.ResponseWriter, _ *http.Request) {
|
|
||||||
flushErr = http.NewResponseController(w).Flush()
|
|
||||||
}
|
|
||||||
|
|
||||||
routers := map[string]http.Handler{
|
|
||||||
server.ProbePattern: server.NewRouterWithProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
tc.sentryEnabled, probe,
|
|
||||||
),
|
|
||||||
server.PageProbePattern: server.NewRouterWithPageProbeForTest(
|
|
||||||
env.log.Get(), env.cfg, env.mw, env.hnd,
|
|
||||||
tc.sentryEnabled, probe,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
for path, router := range routers {
|
|
||||||
flushErr = nil
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
router.ServeHTTP(w, httptest.NewRequestWithContext(
|
|
||||||
t.Context(), http.MethodGet, path, nil,
|
|
||||||
))
|
|
||||||
|
|
||||||
require.NoError(t, flushErr, path)
|
|
||||||
assert.True(
|
|
||||||
t, w.Flushed,
|
|
||||||
"%s: the flush must reach the server's writer", path,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+25
-10
@@ -14,10 +14,11 @@ import (
|
|||||||
// bytes) for form POST endpoints. 1 MB is generous for any form
|
// bytes) for form POST endpoints. 1 MB is generous for any form
|
||||||
// submission while preventing abuse from oversized payloads.
|
// submission while preventing abuse from oversized payloads.
|
||||||
//
|
//
|
||||||
// The four admin page route groups below (/pages, /user/{username},
|
// The five admin page route groups below (/pages, /user/{username},
|
||||||
// /hooks and /hook/{sourceID}) install MaxBodySize(maxFormBodySize)
|
// /settings, /hooks and /hook/{sourceID}) install
|
||||||
// right after their recoverer and error reporting, ahead of both CSRF
|
// MaxBodySize(maxFormBodySize) right after their recoverer and error
|
||||||
// and RequireAuth. Both orderings are deliberate.
|
// reporting, ahead of both CSRF and RequireAuth. Both orderings are
|
||||||
|
// deliberate.
|
||||||
//
|
//
|
||||||
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
// Ahead of CSRF because gorilla/csrf parses the form. The cap has to
|
||||||
// be installed before anything reads the body, or the parse runs
|
// be installed before anything reads the body, or the parse runs
|
||||||
@@ -61,21 +62,16 @@ func (s *Server) SetupRoutes() {
|
|||||||
func (s *Server) setupGlobalMiddleware() {
|
func (s *Server) setupGlobalMiddleware() {
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
|
s.router.Use(s.mw.Logging())
|
||||||
|
|
||||||
// Metrics recording middleware, registered only when the
|
// Metrics recording middleware, registered only when the
|
||||||
// endpoint that exposes what it records is served. The
|
// endpoint that exposes what it records is served. The
|
||||||
// condition is the same MetricsAuthEnabled the /metrics mount
|
// condition is the same MetricsAuthEnabled the /metrics mount
|
||||||
// in setupRoutes reads.
|
// in setupRoutes reads.
|
||||||
//
|
|
||||||
// It goes outside Logging. go-http-metrics' writer passes a flush
|
|
||||||
// on only when the writer inside it has a Flush method, and the
|
|
||||||
// access log's writer has none, so inside Logging a handler's
|
|
||||||
// flush would silently do nothing.
|
|
||||||
if s.params.Config.MetricsAuthEnabled() {
|
if s.params.Config.MetricsAuthEnabled() {
|
||||||
s.router.Use(s.mw.Metrics())
|
s.router.Use(s.mw.Metrics())
|
||||||
}
|
}
|
||||||
|
|
||||||
s.router.Use(s.mw.Logging())
|
|
||||||
s.router.Use(s.mw.CORS())
|
s.router.Use(s.mw.CORS())
|
||||||
s.router.Use(middleware.Timeout(requestTimeout))
|
s.router.Use(middleware.Timeout(requestTimeout))
|
||||||
|
|
||||||
@@ -159,6 +155,7 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
s.setupPageRoutes()
|
s.setupPageRoutes()
|
||||||
s.setupUserRoutes()
|
s.setupUserRoutes()
|
||||||
|
s.setupSettingsRoutes()
|
||||||
s.setupSourceRoutes()
|
s.setupSourceRoutes()
|
||||||
s.setupWebhookRoutes()
|
s.setupWebhookRoutes()
|
||||||
}
|
}
|
||||||
@@ -206,6 +203,24 @@ func (s *Server) setupUserRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||||
|
// configuration comes from the environment and nothing here changes
|
||||||
|
// it.
|
||||||
|
func (s *Server) setupSettingsRoutes() {
|
||||||
|
s.router.Route("/settings", func(r chi.Router) {
|
||||||
|
s.recoverPanics(
|
||||||
|
r, s.h.HandleErrorPage(http.StatusInternalServerError),
|
||||||
|
)
|
||||||
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
|
r.Use(s.mw.CSRF(s.h.HandleErrorPage(http.StatusForbidden)))
|
||||||
|
r.Use(s.mw.NoCache())
|
||||||
|
r.Use(s.mw.RequireAuth())
|
||||||
|
r.Get("/", s.h.HandleSettings())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/hooks", func(r chi.Router) {
|
s.router.Route("/hooks", func(r chi.Router) {
|
||||||
s.recoverPanics(
|
s.recoverPanics(
|
||||||
|
|||||||
@@ -104,6 +104,10 @@ func newTestEnvWithConfig(
|
|||||||
|
|
||||||
app := fxtest.New(
|
app := fxtest.New(
|
||||||
t,
|
t,
|
||||||
|
// fx's own log is discarded, not sent to t.Logf: a hook still
|
||||||
|
// running after a start or stop timeout would write there after
|
||||||
|
// the test has returned.
|
||||||
|
fx.NopLogger,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
w := env.get("/settings", nil)
|
||||||
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(
|
||||||
|
t, "/pages/login?next=%2Fsettings", w.Header().Get("Location"),
|
||||||
|
)
|
||||||
|
|
||||||
|
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text">Webhooks</a>
|
<a href="/hooks" class="btn-text">Webhooks</a>
|
||||||
|
<a href="/settings" class="btn-text">Settings</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/hooks" class="btn-text w-full text-left">Webhooks</a>
|
||||||
|
<a href="/settings" class="btn-text w-full text-left">Settings</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
{{if .CSRFToken}}
|
{{if .CSRFToken}}
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Settings - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Settings}}
|
||||||
|
<div class="p-4">
|
||||||
|
<!-- A value too wide to sit beside its name moves to the
|
||||||
|
next line, where a list breaks only at the spaces
|
||||||
|
between its entries. overflow-wrap: anywhere breaks
|
||||||
|
inside a value only when it alone is wider than the
|
||||||
|
line; an inline style, because the committed
|
||||||
|
tailwind.css has no class for it. -->
|
||||||
|
<div class="flex flex-wrap justify-between items-start gap-4">
|
||||||
|
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
||||||
|
<code class="text-sm text-gray-900" style="overflow-wrap: anywhere">{{.Value}}</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
Reference in New Issue
Block a user