Compare commits
1 Commits
issue-90-b
...
issue-80-c
| Author | SHA1 | Date | |
|---|---|---|---|
| 985464dcf9 |
34
README.md
34
README.md
@@ -89,10 +89,27 @@ TTY detection, and security headers are always applied.
|
|||||||
| `PORT` | HTTP listen port | `8080` |
|
| `PORT` | HTTP listen port | `8080` |
|
||||||
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
| `DATA_DIR` | Directory for all SQLite databases | `/var/lib/webhooker` |
|
||||||
| `DEBUG` | Enable debug logging | `false` |
|
| `DEBUG` | Enable debug logging | `false` |
|
||||||
|
| `MAINTENANCE_MODE` | Serve the maintenance page | `false` |
|
||||||
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
| `METRICS_USERNAME` | Basic auth username for `/metrics` | `""` |
|
||||||
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
| `METRICS_PASSWORD` | Basic auth password for `/metrics` | `""` |
|
||||||
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
| `SENTRY_DSN` | Sentry error reporting DSN | `""` |
|
||||||
|
|
||||||
|
#### Invalid values abort startup
|
||||||
|
|
||||||
|
The defaults above apply **only** to variables that are unset (or set
|
||||||
|
to an empty string). A variable that is set but cannot be parsed is a
|
||||||
|
fatal configuration error: webhooker logs the offending variable and
|
||||||
|
its value and refuses to start, rather than silently running with a
|
||||||
|
substituted default. `PORT=eighty`, `DEBUG=ture`, and
|
||||||
|
`RETENTION_SWEEP_INTERVAL=1 hour` all abort startup. `PORT` must
|
||||||
|
additionally be a number in the range 1–65535.
|
||||||
|
|
||||||
|
Boolean variables (`DEBUG`, `MAINTENANCE_MODE`) accept exactly the
|
||||||
|
spellings Go's `strconv.ParseBool` accepts — `1`, `t`, `T`, `TRUE`,
|
||||||
|
`true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, `False` — and nothing
|
||||||
|
else. `yes`, `on`, and `off` are rejected rather than quietly treated
|
||||||
|
as false.
|
||||||
|
|
||||||
On first startup, webhooker automatically generates a cryptographically
|
On first startup, webhooker automatically generates a cryptographically
|
||||||
secure session encryption key and stores it in the database. This key
|
secure session encryption key and stores it in the database. This key
|
||||||
persists across restarts — no manual key management is needed.
|
persists across restarts — no manual key management is needed.
|
||||||
@@ -867,17 +884,9 @@ Applied to all routes in this order:
|
|||||||
8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set;
|
8. **Sentry** — Error reporting to Sentry (if `SENTRY_DSN` is set;
|
||||||
configured with `Repanic: true` so panics still reach Recoverer)
|
configured with `Repanic: true` so panics still reach Recoverer)
|
||||||
|
|
||||||
Additionally, form endpoints (`/pages`, `/user/*`, `/sources`,
|
Additionally, form endpoints (`/pages`, `/sources`, `/source/*`) apply a
|
||||||
`/source/*`) apply a **MaxBodySize** middleware that limits
|
**MaxBodySize** middleware that limits POST/PUT/PATCH request bodies to
|
||||||
POST/PUT/PATCH request bodies to 1 MB. It is registered ahead of the
|
1 MB using `http.MaxBytesReader`, preventing oversized form submissions.
|
||||||
CSRF middleware in every one of those route groups, because
|
|
||||||
gorilla/csrf parses the form; if the cap were installed after it, form
|
|
||||||
parsing would run under net/http's 10 MB default and the 1 MB limit
|
|
||||||
would never apply. A request that declares a `Content-Length` over the
|
|
||||||
limit is answered with `413 Request Entity Too Large` before any other
|
|
||||||
middleware or handler runs; a chunked request, or one that lies about
|
|
||||||
its length, is hard-capped by `http.MaxBytesReader` and fails
|
|
||||||
downstream at form-parse time.
|
|
||||||
|
|
||||||
### Authentication
|
### Authentication
|
||||||
|
|
||||||
@@ -899,8 +908,7 @@ downstream at form-parse time.
|
|||||||
- Production security headers on all responses: HSTS, X-Content-Type-Options
|
- Production security headers on all responses: HSTS, X-Content-Type-Options
|
||||||
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy,
|
(`nosniff`), X-Frame-Options (`DENY`), Content-Security-Policy, Referrer-Policy,
|
||||||
and Permissions-Policy
|
and Permissions-Policy
|
||||||
- Request body size limits (1 MB) on all form POST endpoints, enforced
|
- Request body size limits (1 MB) on all form POST endpoints
|
||||||
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`, `/sources`, `/source`, and
|
HMAC authentication). Applied to `/pages`, `/sources`, `/source`, and
|
||||||
|
|||||||
35
TODO.md
35
TODO.md
@@ -10,31 +10,28 @@
|
|||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags exist. main (afe88c6) is a working webhook proxy
|
pre-1.0. No git tags exist. main (4f5ecb1) is a working webhook proxy
|
||||||
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
|
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
|
||||||
policy compliance (#6), and pinned lint tooling (#55). Note: TODO.md was
|
policy compliance (#6), pinned lint tooling (#55), a per-webhook event
|
||||||
deliberately deleted from this repo in f9a9569 (2026-03-01, #6); its
|
retention reaper (#63), and fail-loud configuration parsing (#80). Note:
|
||||||
content was folded into the README TODO section, which this draft
|
TODO.md was deliberately deleted from this repo in f9a9569 (2026-03-01,
|
||||||
reconstructs as of 2026-07-06.
|
#6); its content was folded into the README TODO section, which this
|
||||||
|
draft reconstructs as of 2026-07-06.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
Implement automatic event retention cleanup based on retention_days: a
|
Manual event redelivery from the web UI (replay is a core promised
|
||||||
periodic maintenance job that deletes Events, Deliveries, and
|
capability in the README rationale).
|
||||||
DeliveryResults older than the parent webhook's retention_days from each
|
|
||||||
per-webhook event database. The field exists on the Webhook model and
|
|
||||||
the README promises the behavior, but nothing enforces it, so event
|
|
||||||
databases currently grow without bound.
|
|
||||||
|
|
||||||
# Completed Steps
|
# Completed Steps
|
||||||
|
|
||||||
- 2026-08-09 Enforce the request body size limit before the CSRF
|
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
|
||||||
middleware parses the form (#90): `MaxBodySize` is now registered
|
environment values: `envInt` removed in favour of `envPositiveInt`
|
||||||
ahead of `CSRF()` in every form route group, the `/user/{username}`
|
plus a `PORT` range check, `envBool` now parses with
|
||||||
group gained the cap it never had (which is where `POST /password`
|
`strconv.ParseBool`, and defaults apply only to unset variables (#80)
|
||||||
lives), the middleware rejects a declared-oversize body with a real
|
- 2026-08-07 Automatic event retention cleanup based on
|
||||||
413 up front, and the redundant handler-local
|
`retention_days`, deleting expired events, deliveries, and delivery
|
||||||
`http.MaxBytesReader` calls were removed
|
results from each per-webhook event database (#63)
|
||||||
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
|
||||||
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
|
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
|
||||||
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
|
adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
|
||||||
@@ -63,8 +60,6 @@ databases currently grow without bound.
|
|||||||
|
|
||||||
# Future Steps
|
# Future Steps
|
||||||
|
|
||||||
- Manual event redelivery from the web UI (replay is a core promised
|
|
||||||
capability in the README rationale)
|
|
||||||
- Delivery status and retry management UI
|
- Delivery status and retry management UI
|
||||||
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
- Per-webhook rate limiting in the receiver handler (per-webhook config
|
||||||
plus handler enforcement; global limits must not apply to receiver
|
plus handler enforcement; global limits must not apply to receiver
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -31,12 +30,24 @@ const (
|
|||||||
// defaultRetentionSweepInterval is how often the retention
|
// defaultRetentionSweepInterval is how often the retention
|
||||||
// reaper deletes events older than each webhook's RetentionDays.
|
// reaper deletes events older than each webhook's RetentionDays.
|
||||||
defaultRetentionSweepInterval = time.Hour
|
defaultRetentionSweepInterval = time.Hour
|
||||||
|
|
||||||
|
// maxPort is the highest valid TCP port number. The lower
|
||||||
|
// bound (at least 1) is enforced by envPositiveInt.
|
||||||
|
maxPort = 65535
|
||||||
)
|
)
|
||||||
|
|
||||||
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
|
||||||
// contains an unrecognised value.
|
// contains an unrecognised value.
|
||||||
var ErrInvalidEnvironment = errors.New("invalid environment")
|
var ErrInvalidEnvironment = errors.New("invalid environment")
|
||||||
|
|
||||||
|
// ErrNonPositiveValue is returned when an environment variable that
|
||||||
|
// requires a positive integer is set to zero or a negative number.
|
||||||
|
var ErrNonPositiveValue = errors.New("value must be positive")
|
||||||
|
|
||||||
|
// ErrInvalidPort is returned when an environment variable holding a
|
||||||
|
// TCP port number is set above the valid port range.
|
||||||
|
var ErrInvalidPort = errors.New("invalid port")
|
||||||
|
|
||||||
//nolint:revive // ConfigParams is a standard fx naming convention.
|
//nolint:revive // ConfigParams is a standard fx naming convention.
|
||||||
type ConfigParams struct {
|
type ConfigParams struct {
|
||||||
fx.In
|
fx.In
|
||||||
@@ -81,27 +92,81 @@ func envString(key string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// envBool returns the value of the named environment variable
|
// envBool returns the value of the named environment variable
|
||||||
// parsed as a boolean. Returns defaultValue if not set.
|
// parsed as a boolean. Returns defaultValue if not set. If the
|
||||||
func envBool(key string, defaultValue bool) bool {
|
// variable is set but cannot be parsed, it returns a wrapped error
|
||||||
if v := os.Getenv(key); v != "" {
|
// naming the key and the bad value, so startup fails loudly rather
|
||||||
return strings.EqualFold(v, "true") || v == "1"
|
// than silently falling back to the default.
|
||||||
|
//
|
||||||
|
// Parsing is strconv.ParseBool, which accepts 1, t, T, TRUE, true,
|
||||||
|
// True, 0, f, F, FALSE, false and False. Anything else — "yes",
|
||||||
|
// "on", or a typo like "ture" — is an error rather than a silent
|
||||||
|
// false.
|
||||||
|
func envBool(key string, defaultValue bool) (bool, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultValue
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf(
|
||||||
|
"invalid boolean for %s: %q: %w", key, v, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// envInt returns the value of the named environment variable
|
// envPositiveInt returns the value of the named environment variable
|
||||||
// parsed as an integer. Returns defaultValue if not set or
|
// parsed as a positive integer. Returns defaultValue if not set. If
|
||||||
// unparseable.
|
// the variable is set but cannot be parsed, or parses to less than
|
||||||
func envInt(key string, defaultValue int) int {
|
// one, it returns a wrapped error naming the key and the bad value,
|
||||||
if v := os.Getenv(key); v != "" {
|
// so startup fails loudly rather than silently falling back to the
|
||||||
i, err := strconv.Atoi(v)
|
// default.
|
||||||
if err == nil {
|
func envPositiveInt(
|
||||||
return i
|
key string,
|
||||||
}
|
defaultValue int,
|
||||||
|
) (int, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return defaultValue, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return defaultValue
|
i, err := strconv.Atoi(v)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"invalid integer for %s: %q: %w", key, v, err,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if i < 1 {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: %s must be at least 1, got %q",
|
||||||
|
ErrNonPositiveValue, key, v,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return i, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envPort returns the value of the named environment variable parsed
|
||||||
|
// as a TCP port number. Returns defaultValue if not set. A set value
|
||||||
|
// that is unparseable, below 1, or above maxPort is a hard error
|
||||||
|
// naming the key and the bad value.
|
||||||
|
func envPort(key string, defaultValue int) (int, error) {
|
||||||
|
port, err := envPositiveInt(key, defaultValue)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if port > maxPort {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"%w: %s must be at most %d, got %d",
|
||||||
|
ErrInvalidPort, key, maxPort, port,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return port, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// envDuration returns the value of the named environment variable
|
// envDuration returns the value of the named environment variable
|
||||||
@@ -128,32 +193,52 @@ func envDuration(
|
|||||||
return d, nil
|
return d, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Config by reading environment variables.
|
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
|
||||||
//
|
// dev, and rejects unrecognised values.
|
||||||
//nolint:revive // lc parameter is required by fx even if unused.
|
func resolveEnvironment() (string, error) {
|
||||||
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|
||||||
log := params.Logger.Get()
|
|
||||||
|
|
||||||
// Determine environment from WEBHOOKER_ENVIRONMENT env var,
|
|
||||||
// default to dev
|
|
||||||
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
|
||||||
if environment == "" {
|
if environment == "" {
|
||||||
environment = EnvironmentDev
|
environment = EnvironmentDev
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate environment
|
|
||||||
if environment != EnvironmentDev &&
|
if environment != EnvironmentDev &&
|
||||||
environment != EnvironmentProd {
|
environment != EnvironmentProd {
|
||||||
return nil, fmt.Errorf(
|
return "", fmt.Errorf(
|
||||||
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
|
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
|
||||||
ErrInvalidEnvironment,
|
ErrInvalidEnvironment,
|
||||||
EnvironmentDev, EnvironmentProd, environment,
|
EnvironmentDev, EnvironmentProd, environment,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse the retention sweep interval; a set-but-unparseable value
|
return environment, nil
|
||||||
// is a hard error so fx aborts startup rather than silently using
|
}
|
||||||
// the default.
|
|
||||||
|
// loadFromEnv builds a Config from the environment. Every value that
|
||||||
|
// needs parsing fails loudly when it is set but unparseable: the
|
||||||
|
// documented defaults apply only to variables that are unset (or
|
||||||
|
// empty), never as a substitute for a value the operator actually
|
||||||
|
// provided.
|
||||||
|
func loadFromEnv() (*Config, error) {
|
||||||
|
environment, err := resolveEnvironment()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
port, err := envPort("PORT", defaultPort)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
debug, err := envBool("DEBUG", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
retentionSweepInterval, err := envDuration(
|
retentionSweepInterval, err := envDuration(
|
||||||
"RETENTION_SWEEP_INTERVAL",
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
defaultRetentionSweepInterval,
|
defaultRetentionSweepInterval,
|
||||||
@@ -162,21 +247,36 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load configuration values from environment variables
|
return &Config{
|
||||||
s := &Config{
|
|
||||||
DataDir: envString("DATA_DIR"),
|
DataDir: envString("DATA_DIR"),
|
||||||
Debug: envBool("DEBUG", false),
|
Debug: debug,
|
||||||
MaintenanceMode: envBool("MAINTENANCE_MODE", false),
|
MaintenanceMode: maintenanceMode,
|
||||||
Environment: environment,
|
Environment: environment,
|
||||||
MetricsUsername: envString("METRICS_USERNAME"),
|
MetricsUsername: envString("METRICS_USERNAME"),
|
||||||
MetricsPassword: envString("METRICS_PASSWORD"),
|
MetricsPassword: envString("METRICS_PASSWORD"),
|
||||||
Port: envInt("PORT", defaultPort),
|
Port: port,
|
||||||
SentryDSN: envString("SENTRY_DSN"),
|
SentryDSN: envString("SENTRY_DSN"),
|
||||||
RetentionSweepInterval: retentionSweepInterval,
|
RetentionSweepInterval: retentionSweepInterval,
|
||||||
log: log,
|
}, nil
|
||||||
params: ¶ms,
|
}
|
||||||
|
|
||||||
|
// New creates a Config by reading environment variables.
|
||||||
|
//
|
||||||
|
//nolint:revive // lc parameter is required by fx even if unused.
|
||||||
|
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
|
||||||
|
log := params.Logger.Get()
|
||||||
|
|
||||||
|
// A set-but-unparseable value anywhere in the environment is a
|
||||||
|
// hard error, so fx aborts startup rather than running with a
|
||||||
|
// silently substituted default.
|
||||||
|
s, err := loadFromEnv()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
s.log = log
|
||||||
|
s.params = ¶ms
|
||||||
|
|
||||||
// Set default DataDir. All SQLite databases (main application
|
// Set default DataDir. All SQLite databases (main application
|
||||||
// DB and per-webhook event DBs) live here. The same default is
|
// DB and per-webhook event DBs) live here. The same default is
|
||||||
// used regardless of environment; override with DATA_DIR if
|
// used regardless of environment; override with DATA_DIR if
|
||||||
|
|||||||
409
internal/config/env_test.go
Normal file
409
internal/config/env_test.go
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
|
"sneak.berlin/go/webhooker/internal/logger"
|
||||||
|
)
|
||||||
|
|
||||||
|
// testEnvKey is a throwaway variable name used only by the helper
|
||||||
|
// tables below, so they cannot disturb real configuration.
|
||||||
|
const testEnvKey = "WEBHOOKER_TEST_VALUE"
|
||||||
|
|
||||||
|
// Real configuration variables exercised by the config.New tests.
|
||||||
|
const (
|
||||||
|
envKeyPort = "PORT"
|
||||||
|
envKeyDebug = "DEBUG"
|
||||||
|
envKeyMaintenanceMode = "MAINTENANCE_MODE"
|
||||||
|
)
|
||||||
|
|
||||||
|
// envBoolCase is one row of the envBool table.
|
||||||
|
type envBoolCase struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
defaultValue bool
|
||||||
|
expectError bool
|
||||||
|
expected bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// envBoolCases is the envBool table, kept out of the test body so
|
||||||
|
// the test itself stays readable.
|
||||||
|
func envBoolCases() []envBoolCase {
|
||||||
|
return []envBoolCase{
|
||||||
|
{
|
||||||
|
name: "unset uses default false",
|
||||||
|
defaultValue: false,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unset uses default true",
|
||||||
|
defaultValue: true,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty uses default true",
|
||||||
|
set: true,
|
||||||
|
value: "",
|
||||||
|
defaultValue: true,
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "true is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "true",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "one is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "1",
|
||||||
|
expected: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "False is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "False",
|
||||||
|
defaultValue: true,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "0",
|
||||||
|
defaultValue: true,
|
||||||
|
expected: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "yes is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "yes",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "on is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "on",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "typo is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "ture",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvBool(t *testing.T) {
|
||||||
|
for _, tt := range envBoolCases() {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
// Cannot use t.Parallel() here because t.Setenv
|
||||||
|
// is incompatible with parallel subtests.
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvBoolForTest(
|
||||||
|
testEnvKey, tt.defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvPositiveInt(t *testing.T) {
|
||||||
|
const defaultValue = 7
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
errIs error
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unset returns the default integer",
|
||||||
|
expected: defaultValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty returns the default integer",
|
||||||
|
set: true,
|
||||||
|
value: "",
|
||||||
|
expected: defaultValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "positive value is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "42",
|
||||||
|
expected: 42,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable value is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "not-a-number",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "0",
|
||||||
|
expectError: true,
|
||||||
|
errIs: config.ErrNonPositiveValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "-5",
|
||||||
|
expectError: true,
|
||||||
|
errIs: config.ErrNonPositiveValue,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvPositiveIntForTest(
|
||||||
|
testEnvKey, defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
|
||||||
|
if tt.errIs != nil {
|
||||||
|
require.ErrorIs(t, err, tt.errIs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestEnvPort(t *testing.T) {
|
||||||
|
const defaultValue = 8080
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
set bool
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
errIs error
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "unset returns the default port",
|
||||||
|
expected: defaultValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid port is parsed",
|
||||||
|
set: true,
|
||||||
|
value: "9000",
|
||||||
|
expected: 9000,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "highest port is accepted",
|
||||||
|
set: true,
|
||||||
|
value: "65535",
|
||||||
|
expected: 65535,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable value is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "not-a-port",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "0",
|
||||||
|
expectError: true,
|
||||||
|
errIs: config.ErrNonPositiveValue,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "above the port range is rejected",
|
||||||
|
set: true,
|
||||||
|
value: "65536",
|
||||||
|
expectError: true,
|
||||||
|
errIs: config.ErrInvalidPort,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
if tt.set {
|
||||||
|
t.Setenv(testEnvKey, tt.value)
|
||||||
|
} else {
|
||||||
|
require.NoError(t, os.Unsetenv(testEnvKey))
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := config.EnvPortForTest(
|
||||||
|
testEnvKey, defaultValue,
|
||||||
|
)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), testEnvKey)
|
||||||
|
|
||||||
|
if tt.errIs != nil {
|
||||||
|
require.ErrorIs(t, err, tt.errIs)
|
||||||
|
}
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.expected, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildConfig constructs a Config through fx exactly as the
|
||||||
|
// application does, returning the config and any construction error.
|
||||||
|
func buildConfig(t *testing.T) (*config.Config, error) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var cfg *config.Config
|
||||||
|
|
||||||
|
app := fx.New(
|
||||||
|
fx.NopLogger,
|
||||||
|
fx.Provide(
|
||||||
|
globals.New,
|
||||||
|
logger.New,
|
||||||
|
config.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&cfg),
|
||||||
|
)
|
||||||
|
|
||||||
|
return cfg, app.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRejectsBadEnvValues(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
key string
|
||||||
|
value string
|
||||||
|
expectError bool
|
||||||
|
check func(t *testing.T, cfg *config.Config)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "valid PORT is used",
|
||||||
|
key: envKeyPort,
|
||||||
|
value: "9001",
|
||||||
|
check: func(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
assert.Equal(t, 9001, cfg.Port)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable PORT aborts startup",
|
||||||
|
key: envKeyPort,
|
||||||
|
value: "eighty-eighty",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "out-of-range PORT aborts startup",
|
||||||
|
key: envKeyPort,
|
||||||
|
value: "70000",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "valid DEBUG is used",
|
||||||
|
key: envKeyDebug,
|
||||||
|
value: "true",
|
||||||
|
check: func(t *testing.T, cfg *config.Config) {
|
||||||
|
t.Helper()
|
||||||
|
assert.True(t, cfg.Debug)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable DEBUG aborts startup",
|
||||||
|
key: envKeyDebug,
|
||||||
|
value: "ture",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unparseable MAINTENANCE_MODE aborts startup",
|
||||||
|
key: envKeyMaintenanceMode,
|
||||||
|
value: "sometimes",
|
||||||
|
expectError: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
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.
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
t.Setenv(tt.key, tt.value)
|
||||||
|
|
||||||
|
cfg, err := buildConfig(t)
|
||||||
|
|
||||||
|
if tt.expectError {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), tt.key)
|
||||||
|
assert.Contains(t, err.Error(), tt.value)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
tt.check(t, cfg)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestNewUsesDefaultsWhenUnset proves the fail-loud behaviour did not
|
||||||
|
// break the legitimate unset case: absent variables still get their
|
||||||
|
// documented defaults.
|
||||||
|
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
|
||||||
|
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
|
||||||
|
|
||||||
|
for _, key := range []string{
|
||||||
|
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.Unsetenv(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := buildConfig(t)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, 8080, cfg.Port)
|
||||||
|
assert.False(t, cfg.Debug)
|
||||||
|
assert.False(t, cfg.MaintenanceMode)
|
||||||
|
}
|
||||||
20
internal/config/export_test.go
Normal file
20
internal/config/export_test.go
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
// This file exposes the unexported environment parsing helpers to
|
||||||
|
// the external config_test package so each helper can be covered by
|
||||||
|
// its own table-driven test without weakening the package API.
|
||||||
|
|
||||||
|
// EnvBoolForTest exposes envBool.
|
||||||
|
func EnvBoolForTest(key string, defaultValue bool) (bool, error) {
|
||||||
|
return envBool(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvPositiveIntForTest exposes envPositiveInt.
|
||||||
|
func EnvPositiveIntForTest(key string, defaultValue int) (int, error) {
|
||||||
|
return envPositiveInt(key, defaultValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// EnvPortForTest exposes envPort.
|
||||||
|
func EnvPortForTest(key string, defaultValue int) (int, error) {
|
||||||
|
return envPort(key, defaultValue)
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@ func (h *Handlers) HandleLoginPage() http.HandlerFunc {
|
|||||||
// HandleLoginSubmit handles the login form submission (POST)
|
// HandleLoginSubmit handles the login form submission (POST)
|
||||||
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
func (h *Handlers) HandleLoginSubmit() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
// The body size cap is enforced by the MaxBodySize
|
// Limit request body to prevent memory exhaustion
|
||||||
// middleware, which runs before CSRF parses the form.
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
|
||||||
|
|
||||||
|
// Parse form data
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ func (h *Handlers) HandlePasswordChange() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
// Limit request body to prevent memory exhaustion.
|
||||||
// middleware, which runs before CSRF parses the form.
|
r.Body = http.MaxBytesReader(w, r.Body, 1<<maxBodyShift)
|
||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.log.Error("failed to parse form", "error", err)
|
h.log.Error("failed to parse form", "error", err)
|
||||||
|
|||||||
@@ -127,8 +127,10 @@ func (h *Handlers) HandleSourceCreateSubmit() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
r.Body = http.MaxBytesReader(
|
||||||
// middleware, which runs before CSRF parses the form.
|
w, r.Body, 1<<maxBodyShift,
|
||||||
|
)
|
||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -384,8 +386,10 @@ func (h *Handlers) HandleSourceEditSubmit() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
r.Body = http.MaxBytesReader(
|
||||||
// middleware, which runs before CSRF parses the form.
|
w, r.Body, 1<<maxBodyShift,
|
||||||
|
)
|
||||||
|
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -405,8 +409,10 @@ func (h *Handlers) applyWebhookEdit(
|
|||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook *database.Webhook,
|
webhook *database.Webhook,
|
||||||
) {
|
) {
|
||||||
// The body size cap is enforced by the MaxBodySize middleware,
|
r.Body = http.MaxBytesReader(
|
||||||
// which runs before CSRF parses the form.
|
w, r.Body, 1<<maxBodyShift,
|
||||||
|
)
|
||||||
|
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
data := map[string]any{
|
data := map[string]any{
|
||||||
@@ -719,8 +725,10 @@ func (h *Handlers) HandleEntrypointCreate() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
r.Body = http.MaxBytesReader(
|
||||||
// middleware, which runs before CSRF parses the form.
|
w, r.Body, 1<<maxBodyShift,
|
||||||
|
)
|
||||||
|
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -777,8 +785,10 @@ func (h *Handlers) HandleTargetCreate() http.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The body size cap is enforced by the MaxBodySize
|
r.Body = http.MaxBytesReader(
|
||||||
// middleware, which runs before CSRF parses the form.
|
w, r.Body, 1<<maxBodyShift,
|
||||||
|
)
|
||||||
|
|
||||||
err = r.ParseForm()
|
err = r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
http.Error(
|
http.Error(
|
||||||
@@ -798,8 +808,10 @@ func (h *Handlers) processTargetCreate(
|
|||||||
r *http.Request,
|
r *http.Request,
|
||||||
webhook database.Webhook,
|
webhook database.Webhook,
|
||||||
) {
|
) {
|
||||||
// The body size cap is enforced by the MaxBodySize middleware,
|
r.Body = http.MaxBytesReader(
|
||||||
// which runs before CSRF parses the form.
|
w, r.Body, 1<<maxBodyShift,
|
||||||
|
)
|
||||||
|
|
||||||
name := r.FormValue("name")
|
name := r.FormValue("name")
|
||||||
targetType := database.TargetType(r.FormValue("type"))
|
targetType := database.TargetType(r.FormValue("type"))
|
||||||
targetURL := r.FormValue("url")
|
targetURL := r.FormValue("url")
|
||||||
|
|||||||
@@ -285,36 +285,10 @@ func (s *Middleware) NoCache() func(http.Handler) http.Handler {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// bodyLimitedMethod reports whether the request method carries a
|
// MaxBodySize returns middleware that limits the request body size
|
||||||
// body that the MaxBodySize middleware should cap.
|
// for POST requests. If the body exceeds the given limit in
|
||||||
func bodyLimitedMethod(method string) bool {
|
// bytes, the server returns 413 Request Entity Too Large. This
|
||||||
return method == http.MethodPost ||
|
// prevents clients from sending arbitrarily large form bodies.
|
||||||
method == http.MethodPut ||
|
|
||||||
method == http.MethodPatch
|
|
||||||
}
|
|
||||||
|
|
||||||
// MaxBodySize returns middleware that limits the size of
|
|
||||||
// POST/PUT/PATCH request bodies to maxBytes. It must be registered
|
|
||||||
// before any middleware that parses the body — notably CSRF, which
|
|
||||||
// calls r.PostFormValue — so that form parsing happens under this
|
|
||||||
// cap rather than net/http's 10 MB default.
|
|
||||||
//
|
|
||||||
// Two enforcement paths exist, because http.MaxBytesReader alone
|
|
||||||
// cannot produce a 413: it reports the overflow as an error from
|
|
||||||
// Read, by which point the body parser downstream has already
|
|
||||||
// converted that error into its own response.
|
|
||||||
//
|
|
||||||
// - Declared oversize: the request announces a Content-Length
|
|
||||||
// greater than maxBytes. The middleware answers 413 Request
|
|
||||||
// Entity Too Large immediately and does not call the next
|
|
||||||
// handler, so neither CSRF nor the endpoint handler runs.
|
|
||||||
// - Undeclared oversize: the request is chunked (Content-Length
|
|
||||||
// of -1) or lies about its Content-Length. There is nothing to
|
|
||||||
// check up front, so http.MaxBytesReader hard-caps the body at
|
|
||||||
// maxBytes and the request fails downstream — the form parse
|
|
||||||
// errors out and CSRF rejects it with 403. The response is less
|
|
||||||
// precise than a 413, but the body is still never buffered
|
|
||||||
// beyond the cap, which is the property that matters.
|
|
||||||
func (s *Middleware) MaxBodySize(
|
func (s *Middleware) MaxBodySize(
|
||||||
maxBytes int64,
|
maxBytes int64,
|
||||||
) func(http.Handler) http.Handler {
|
) func(http.Handler) http.Handler {
|
||||||
@@ -323,31 +297,14 @@ func (s *Middleware) MaxBodySize(
|
|||||||
w http.ResponseWriter,
|
w http.ResponseWriter,
|
||||||
r *http.Request,
|
r *http.Request,
|
||||||
) {
|
) {
|
||||||
if !bodyLimitedMethod(r.Method) {
|
if r.Method == http.MethodPost ||
|
||||||
next.ServeHTTP(w, r)
|
r.Method == http.MethodPut ||
|
||||||
|
r.Method == http.MethodPatch {
|
||||||
return
|
r.Body = http.MaxBytesReader(
|
||||||
}
|
w, r.Body, maxBytes,
|
||||||
|
|
||||||
if r.ContentLength > maxBytes {
|
|
||||||
s.log.Warn(
|
|
||||||
"request body exceeds limit",
|
|
||||||
"method", r.Method,
|
|
||||||
"path", r.URL.Path,
|
|
||||||
"content_length", r.ContentLength,
|
|
||||||
"limit", maxBytes,
|
|
||||||
)
|
)
|
||||||
http.Error(
|
|
||||||
w,
|
|
||||||
"Request Entity Too Large",
|
|
||||||
http.StatusRequestEntityTooLarge,
|
|
||||||
)
|
|
||||||
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(w, r)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ package middleware_test
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/base64"
|
"encoding/base64"
|
||||||
"io"
|
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/gorilla/sessions"
|
"github.com/gorilla/sessions"
|
||||||
@@ -428,153 +426,6 @@ func TestNoCache_SetsHeaders(t *testing.T) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- MaxBodySize Middleware Tests ---
|
|
||||||
|
|
||||||
const testBodyLimit int64 = 64
|
|
||||||
|
|
||||||
// maxBodySizeHandler wraps a sentinel handler in MaxBodySize with
|
|
||||||
// testBodyLimit. The sentinel records whether it ran and how much of
|
|
||||||
// the body it managed to read, so tests can distinguish "never
|
|
||||||
// reached" from "reached but truncated".
|
|
||||||
type maxBodySizeResult struct {
|
|
||||||
called bool
|
|
||||||
read int
|
|
||||||
readErr error
|
|
||||||
response *httptest.ResponseRecorder
|
|
||||||
}
|
|
||||||
|
|
||||||
func runMaxBodySize(
|
|
||||||
t *testing.T,
|
|
||||||
req *http.Request,
|
|
||||||
) *maxBodySizeResult {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
m, _ := testMiddleware(t, config.EnvironmentDev)
|
|
||||||
res := &maxBodySizeResult{response: httptest.NewRecorder()}
|
|
||||||
|
|
||||||
handler := m.MaxBodySize(testBodyLimit)(http.HandlerFunc(
|
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
res.called = true
|
|
||||||
|
|
||||||
body, err := io.ReadAll(r.Body)
|
|
||||||
res.read = len(body)
|
|
||||||
res.readErr = err
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
},
|
|
||||||
))
|
|
||||||
|
|
||||||
handler.ServeHTTP(res.response, req)
|
|
||||||
|
|
||||||
return res
|
|
||||||
}
|
|
||||||
|
|
||||||
// postWithBody builds a POST request whose Content-Length is
|
|
||||||
// accurate for the given payload size.
|
|
||||||
func postWithBody(size int) *http.Request {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodPost, "/pages/login",
|
|
||||||
strings.NewReader(strings.Repeat("a", size)),
|
|
||||||
)
|
|
||||||
req.Header.Set(
|
|
||||||
"Content-Type", "application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxBodySize_DeclaredOversize_413AndHandlerNotReached(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
res := runMaxBodySize(t, postWithBody(int(testBodyLimit)+1))
|
|
||||||
|
|
||||||
assert.False(
|
|
||||||
t, res.called,
|
|
||||||
"handler must not be reached for an oversized body",
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, http.StatusRequestEntityTooLarge, res.response.Code,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxBodySize_AtLimit_PassesThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
res := runMaxBodySize(t, postWithBody(int(testBodyLimit)))
|
|
||||||
|
|
||||||
assert.True(
|
|
||||||
t, res.called,
|
|
||||||
"handler should be reached for a body at the limit",
|
|
||||||
)
|
|
||||||
require.NoError(t, res.readErr)
|
|
||||||
assert.Equal(t, int(testBodyLimit), res.read)
|
|
||||||
assert.Equal(t, http.StatusOK, res.response.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxBodySize_UnderLimit_PassesThrough(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
res := runMaxBodySize(t, postWithBody(1))
|
|
||||||
|
|
||||||
assert.True(t, res.called)
|
|
||||||
require.NoError(t, res.readErr)
|
|
||||||
assert.Equal(t, 1, res.read)
|
|
||||||
assert.Equal(t, http.StatusOK, res.response.Code)
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMaxBodySize_GetWithOversizeBody_NotCapped(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(),
|
|
||||||
http.MethodGet, "/pages/login",
|
|
||||||
strings.NewReader(
|
|
||||||
strings.Repeat("a", int(testBodyLimit)+1),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
res := runMaxBodySize(t, req)
|
|
||||||
|
|
||||||
assert.True(
|
|
||||||
t, res.called,
|
|
||||||
"GET requests are not subject to the POST body cap",
|
|
||||||
)
|
|
||||||
require.NoError(t, res.readErr)
|
|
||||||
assert.Equal(t, int(testBodyLimit)+1, res.read)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestMaxBodySize_UndeclaredOversize_TruncatedAtCap covers the
|
|
||||||
// chunked / lying-Content-Length case: there is nothing to check up
|
|
||||||
// front, so the request reaches the handler but MaxBytesReader
|
|
||||||
// hard-caps the body and the read fails at the limit.
|
|
||||||
func TestMaxBodySize_UndeclaredOversize_TruncatedAtCap(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
req := postWithBody(int(testBodyLimit) + 1)
|
|
||||||
// Simulate a chunked request: no declared length.
|
|
||||||
req.ContentLength = -1
|
|
||||||
|
|
||||||
res := runMaxBodySize(t, req)
|
|
||||||
|
|
||||||
assert.True(
|
|
||||||
t, res.called,
|
|
||||||
"an undeclared oversize body cannot be rejected up front",
|
|
||||||
)
|
|
||||||
require.Error(
|
|
||||||
t, res.readErr,
|
|
||||||
"reading past the cap must fail",
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, int(testBodyLimit), res.read,
|
|
||||||
"the handler must not see more than the cap",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Helper Tests ---
|
// --- Helper Tests ---
|
||||||
|
|
||||||
func TestIpFromHostPort(t *testing.T) {
|
func TestIpFromHostPort(t *testing.T) {
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
package server
|
|
||||||
|
|
||||||
import (
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
// MaxFormBodySizeForTest exposes the form body cap so tests can
|
|
||||||
// build requests that sit exactly at, below, and above it.
|
|
||||||
const MaxFormBodySizeForTest = maxFormBodySize
|
|
||||||
|
|
||||||
// NewRouterForTest builds the real route tree via SetupRoutes with
|
|
||||||
// the supplied middleware and handlers, bypassing the fx lifecycle
|
|
||||||
// and the HTTP listener. Tests use it so that route-group middleware
|
|
||||||
// registration order is exercised exactly as it ships, rather than
|
|
||||||
// against a hand-rebuilt chain that could drift from routes.go.
|
|
||||||
func NewRouterForTest(
|
|
||||||
log *slog.Logger,
|
|
||||||
cfg *config.Config,
|
|
||||||
mw *middleware.Middleware,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
) http.Handler {
|
|
||||||
s := &Server{
|
|
||||||
log: log,
|
|
||||||
mw: mw,
|
|
||||||
h: h,
|
|
||||||
params: ServerParams{Config: cfg},
|
|
||||||
}
|
|
||||||
s.SetupRoutes()
|
|
||||||
|
|
||||||
return s.router
|
|
||||||
}
|
|
||||||
@@ -90,11 +90,9 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupPageRoutes() {
|
func (s *Server) setupPageRoutes() {
|
||||||
s.router.Route("/pages", func(r chi.Router) {
|
s.router.Route("/pages", func(r chi.Router) {
|
||||||
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
||||||
// form, so the cap has to be installed before it runs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
|
|
||||||
r.Group(func(r chi.Router) {
|
r.Group(func(r chi.Router) {
|
||||||
r.Use(s.mw.LoginRateLimit())
|
r.Use(s.mw.LoginRateLimit())
|
||||||
@@ -108,9 +106,6 @@ func (s *Server) setupPageRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupUserRoutes() {
|
func (s *Server) setupUserRoutes() {
|
||||||
s.router.Route("/user/{username}", func(r chi.Router) {
|
s.router.Route("/user/{username}", func(r chi.Router) {
|
||||||
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
||||||
// form, so the cap has to be installed before it runs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
@@ -123,24 +118,20 @@ func (s *Server) setupUserRoutes() {
|
|||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/sources", func(r chi.Router) {
|
||||||
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
||||||
// form, so the cap has to be installed before it runs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Get("/", s.h.HandleSourceList())
|
r.Get("/", s.h.HandleSourceList())
|
||||||
r.Get("/new", s.h.HandleSourceCreate())
|
r.Get("/new", s.h.HandleSourceCreate())
|
||||||
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
r.Post("/new", s.h.HandleSourceCreateSubmit())
|
||||||
})
|
})
|
||||||
|
|
||||||
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
s.router.Route("/source/{sourceID}", func(r chi.Router) {
|
||||||
// MaxBodySize must precede CSRF: gorilla/csrf parses the
|
|
||||||
// form, so the cap has to be installed before it runs.
|
|
||||||
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
|
||||||
r.Use(s.mw.CSRF())
|
r.Use(s.mw.CSRF())
|
||||||
r.Use(s.mw.NoCache())
|
r.Use(s.mw.NoCache())
|
||||||
r.Use(s.mw.RequireAuth())
|
r.Use(s.mw.RequireAuth())
|
||||||
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
r.Get("/", s.h.HandleSourceDetail())
|
r.Get("/", s.h.HandleSourceDetail())
|
||||||
r.Get("/edit", s.h.HandleSourceEdit())
|
r.Get("/edit", s.h.HandleSourceEdit())
|
||||||
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
r.Post("/edit", s.h.HandleSourceEditSubmit())
|
||||||
|
|||||||
@@ -1,375 +0,0 @@
|
|||||||
package server_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"html"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/healthcheck"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/middleware"
|
|
||||||
"sneak.berlin/go/webhooker/internal/server"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// csrfCookieName is the cookie gorilla/csrf issues when it runs. Its
|
|
||||||
// presence or absence on a response is how these tests tell whether
|
|
||||||
// the CSRF middleware executed.
|
|
||||||
const csrfCookieName = "_gorilla_csrf"
|
|
||||||
|
|
||||||
type noopNotifier struct{}
|
|
||||||
|
|
||||||
func (n *noopNotifier) Notify([]delivery.Task) {}
|
|
||||||
|
|
||||||
// testEnv is the real router from routes.go plus the collaborators
|
|
||||||
// tests need to seed users and forge sessions.
|
|
||||||
type testEnv struct {
|
|
||||||
router http.Handler
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
}
|
|
||||||
|
|
||||||
// newTestEnv wires the dependency graph with fx and builds the
|
|
||||||
// production route tree, so middleware registration order is
|
|
||||||
// exercised exactly as it ships.
|
|
||||||
func newTestEnv(t *testing.T) *testEnv {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var (
|
|
||||||
log *logger.Logger
|
|
||||||
cfg *config.Config
|
|
||||||
mw *middleware.Middleware
|
|
||||||
hnd *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := fxtest.New(
|
|
||||||
t,
|
|
||||||
fx.Provide(
|
|
||||||
globals.New,
|
|
||||||
logger.New,
|
|
||||||
func() *config.Config {
|
|
||||||
return &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
Environment: config.EnvironmentDev,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
database.New,
|
|
||||||
database.NewWebhookDBManager,
|
|
||||||
healthcheck.New,
|
|
||||||
session.New,
|
|
||||||
func() delivery.Notifier { return &noopNotifier{} },
|
|
||||||
middleware.New,
|
|
||||||
handlers.New,
|
|
||||||
),
|
|
||||||
fx.Populate(&log, &cfg, &mw, &hnd, &sess, &db),
|
|
||||||
)
|
|
||||||
app.RequireStart()
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
return &testEnv{
|
|
||||||
router: server.NewRouterForTest(log.Get(), cfg, mw, hnd),
|
|
||||||
sess: sess,
|
|
||||||
db: db,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// oversizeValue returns a form value one byte past the route-group
|
|
||||||
// body cap, so an encoded form containing it is guaranteed oversize.
|
|
||||||
func oversizeValue() string {
|
|
||||||
return strings.Repeat("a", int(server.MaxFormBodySizeForTest)+1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfCookieSet reports whether the response issued a gorilla/csrf
|
|
||||||
// cookie, which only happens if the CSRF middleware ran.
|
|
||||||
func csrfCookieSet(w *httptest.ResponseRecorder) bool {
|
|
||||||
for _, c := range w.Result().Cookies() {
|
|
||||||
if c.Name == csrfCookieName {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// get issues a GET through the router with the supplied cookies.
|
|
||||||
func (e *testEnv) get(
|
|
||||||
path string,
|
|
||||||
cookies []*http.Cookie,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, path, nil,
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, c := range cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
e.router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// post issues a urlencoded form POST through the router. The body is
|
|
||||||
// a strings.Reader, so the request carries an accurate
|
|
||||||
// Content-Length — the signal MaxBodySize checks up front.
|
|
||||||
func (e *testEnv) post(
|
|
||||||
path string,
|
|
||||||
form url.Values,
|
|
||||||
cookies []*http.Cookie,
|
|
||||||
) *httptest.ResponseRecorder {
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodPost, path,
|
|
||||||
strings.NewReader(form.Encode()),
|
|
||||||
)
|
|
||||||
req.Header.Set(
|
|
||||||
"Content-Type", "application/x-www-form-urlencoded",
|
|
||||||
)
|
|
||||||
|
|
||||||
for _, c := range cookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
e.router.ServeHTTP(w, req)
|
|
||||||
|
|
||||||
return w
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfFrom renders the page at path and returns the CSRF token from
|
|
||||||
// its form together with every cookie needed for the follow-up POST.
|
|
||||||
func (e *testEnv) csrfFrom(
|
|
||||||
t *testing.T,
|
|
||||||
path string,
|
|
||||||
cookies []*http.Cookie,
|
|
||||||
) (string, []*http.Cookie) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
w := e.get(path, cookies)
|
|
||||||
require.Equal(t, http.StatusOK, w.Code)
|
|
||||||
|
|
||||||
pattern := regexp.MustCompile(
|
|
||||||
`name="csrf_token" value="([^"]+)"`,
|
|
||||||
)
|
|
||||||
|
|
||||||
match := pattern.FindStringSubmatch(w.Body.String())
|
|
||||||
require.Len(t, match, 2, "form must embed a CSRF token")
|
|
||||||
|
|
||||||
// html/template escapes "+" and "=" in attribute values, and
|
|
||||||
// gorilla/csrf tokens are standard base64, so the value read
|
|
||||||
// out of the markup has to be unescaped before it is submitted.
|
|
||||||
token := html.UnescapeString(match[1])
|
|
||||||
|
|
||||||
combined := make([]*http.Cookie, 0, len(cookies))
|
|
||||||
combined = append(combined, cookies...)
|
|
||||||
combined = append(combined, w.Result().Cookies()...)
|
|
||||||
|
|
||||||
return token, combined
|
|
||||||
}
|
|
||||||
|
|
||||||
// authCookies forges an authenticated session for the given user.
|
|
||||||
func (e *testEnv) authCookies(
|
|
||||||
t *testing.T,
|
|
||||||
userID, username string,
|
|
||||||
) []*http.Cookie {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/setup", nil,
|
|
||||||
)
|
|
||||||
w := httptest.NewRecorder()
|
|
||||||
|
|
||||||
s, err := e.sess.Get(req)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
e.sess.SetUser(s, userID, username)
|
|
||||||
require.NoError(t, e.sess.Save(req, w, s))
|
|
||||||
|
|
||||||
cookies := w.Result().Cookies()
|
|
||||||
require.NotEmpty(t, cookies, "session cookie should be set")
|
|
||||||
|
|
||||||
return cookies
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedUser creates a user with the given password and returns the
|
|
||||||
// stored hash so tests can assert whether it later changed.
|
|
||||||
func (e *testEnv) seedUser(
|
|
||||||
t *testing.T,
|
|
||||||
username, password string,
|
|
||||||
) (string, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
hash, err := database.HashPassword(password)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
user := &database.User{Username: username, Password: hash}
|
|
||||||
require.NoError(t, e.db.DB().Create(user).Error)
|
|
||||||
|
|
||||||
return user.ID, hash
|
|
||||||
}
|
|
||||||
|
|
||||||
// storedHash reads the current password hash for a username.
|
|
||||||
func (e *testEnv) storedHash(t *testing.T, username string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var user database.User
|
|
||||||
|
|
||||||
require.NoError(t,
|
|
||||||
e.db.DB().Where("username = ?", username).
|
|
||||||
First(&user).Error,
|
|
||||||
)
|
|
||||||
|
|
||||||
return user.Password
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /pages group ---
|
|
||||||
|
|
||||||
// TestPagesLogin_OversizeBody_RejectedBeforeCSRF proves the cap runs
|
|
||||||
// ahead of gorilla/csrf: the response is a clean 413 and no CSRF
|
|
||||||
// cookie was issued, so neither the CSRF middleware nor the login
|
|
||||||
// handler ran.
|
|
||||||
func TestPagesLogin_OversizeBody_RejectedBeforeCSRF(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", oversizeValue())
|
|
||||||
form.Set("password", "irrelevant")
|
|
||||||
|
|
||||||
w := env.post("/pages/login", form, nil)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, http.StatusRequestEntityTooLarge, w.Code,
|
|
||||||
)
|
|
||||||
assert.False(
|
|
||||||
t, csrfCookieSet(w),
|
|
||||||
"CSRF middleware must not run for an oversized body",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPagesLogin_UnderLimit_NoToken_CSRFRejects is the control for
|
|
||||||
// the test above: an identically shaped but under-limit POST does
|
|
||||||
// reach gorilla/csrf, which rejects it and issues its cookie. Without
|
|
||||||
// this, the missing-cookie assertion above would prove nothing.
|
|
||||||
func TestPagesLogin_UnderLimit_NoToken_CSRFRejects(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("username", "someone")
|
|
||||||
form.Set("password", "irrelevant")
|
|
||||||
|
|
||||||
w := env.post("/pages/login", form, nil)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusForbidden, w.Code)
|
|
||||||
assert.True(
|
|
||||||
t, csrfCookieSet(w),
|
|
||||||
"CSRF middleware should run for an under-limit body",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPagesLogin_UnderLimit_ValidToken_ReachesHandler proves the
|
|
||||||
// reorder did not break CSRF token handling: a token harvested from
|
|
||||||
// the rendered login form is still accepted and the request lands in
|
|
||||||
// the handler.
|
|
||||||
func TestPagesLogin_UnderLimit_ValidToken_ReachesHandler(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
token, cookies := env.csrfFrom(t, "/pages/login", nil)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("username", "nosuchuser")
|
|
||||||
form.Set("password", "wrongpassword")
|
|
||||||
|
|
||||||
w := env.post("/pages/login", form, cookies)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusUnauthorized, w.Code)
|
|
||||||
assert.Contains(
|
|
||||||
t, w.Body.String(), "Invalid username or password",
|
|
||||||
"request should reach the login handler",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- /user/{username} group ---
|
|
||||||
|
|
||||||
// TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged
|
|
||||||
// covers the route that previously had no middleware body cap at
|
|
||||||
// all. The request carries a valid session and a valid CSRF token,
|
|
||||||
// so the only thing that can stop it is the size cap; the unchanged
|
|
||||||
// password hash is the observable proof the handler never ran.
|
|
||||||
func TestPasswordChange_OversizeBody_RejectedAndPasswordUnchanged(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, originalHash := env.seedUser(t, "pwuser", "oldpassword")
|
|
||||||
cookies := env.authCookies(t, userID, "pwuser")
|
|
||||||
token, cookies := env.csrfFrom(t, "/user/pwuser/", cookies)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("current_password", "oldpassword")
|
|
||||||
form.Set("new_password", oversizeValue())
|
|
||||||
form.Set("confirm_password", oversizeValue())
|
|
||||||
|
|
||||||
w := env.post("/user/pwuser/password", form, cookies)
|
|
||||||
|
|
||||||
assert.Equal(
|
|
||||||
t, http.StatusRequestEntityTooLarge, w.Code,
|
|
||||||
)
|
|
||||||
assert.Equal(
|
|
||||||
t, originalHash, env.storedHash(t, "pwuser"),
|
|
||||||
"handler must not run, so the password must be unchanged",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestPasswordChange_UnderLimit_Succeeds proves that adding the cap
|
|
||||||
// to the /user/{username} group did not break the route it guards.
|
|
||||||
func TestPasswordChange_UnderLimit_Succeeds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := newTestEnv(t)
|
|
||||||
|
|
||||||
userID, originalHash := env.seedUser(t, "okuser", "oldpassword")
|
|
||||||
cookies := env.authCookies(t, userID, "okuser")
|
|
||||||
token, cookies := env.csrfFrom(t, "/user/okuser/", cookies)
|
|
||||||
|
|
||||||
form := url.Values{}
|
|
||||||
form.Set("csrf_token", token)
|
|
||||||
form.Set("current_password", "oldpassword")
|
|
||||||
form.Set("new_password", "brandnewpassword")
|
|
||||||
form.Set("confirm_password", "brandnewpassword")
|
|
||||||
|
|
||||||
w := env.post("/user/okuser/password", form, cookies)
|
|
||||||
|
|
||||||
assert.Equal(t, http.StatusOK, w.Code)
|
|
||||||
assert.NotEqual(
|
|
||||||
t, originalHash, env.storedHash(t, "okuser"),
|
|
||||||
"an under-limit password change should still apply",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user