Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9e745f7b84 | ||
|
|
b5452d744d | ||
|
|
2d805125ee |
+2
-3
@@ -67,9 +67,8 @@ RUN adduser -D -H -s /sbin/nologin pixad && \
|
||||
mkdir -p /var/lib/pixa /etc/pixa && \
|
||||
chown pixad:pixad /var/lib/pixa
|
||||
|
||||
# Copy the image config; signing_key comes from PIXA_SIGNING_KEY.
|
||||
# Mount a file over /etc/pixa/config.yml to override anything else.
|
||||
COPY config.docker.yml /etc/pixa/config.yml
|
||||
# Copy default config (edit signing_key before use)
|
||||
COPY config.example.yml /etc/pixa/config.yml
|
||||
|
||||
USER pixad
|
||||
WORKDIR /var/lib/pixa
|
||||
|
||||
@@ -15,25 +15,14 @@ git clone https://git.eeqj.de/sneak/pixa.git
|
||||
cd pixa
|
||||
make build
|
||||
|
||||
# run with a config file: copy the example and set a real signing key
|
||||
# (the example placeholder is refused at startup), e.g. with
|
||||
# openssl rand -base64 32
|
||||
cp config.example.yml config.yml
|
||||
$EDITOR config.yml # replace the signing_key placeholder
|
||||
./bin/pixad --config config.yml
|
||||
# run with a config file
|
||||
./bin/pixad --config config.example.yml
|
||||
|
||||
# or build and run via Docker
|
||||
make docker
|
||||
docker run -p 8080:8080 -e PIXA_SIGNING_KEY="$(openssl rand -base64 32)" pixa:latest
|
||||
docker run -p 8080:8080 pixad:latest
|
||||
```
|
||||
|
||||
A container is configured two ways. The signing key comes from the
|
||||
`PIXA_SIGNING_KEY` environment variable, which the baked-in config
|
||||
reads; if it is unset the container exits at startup naming the
|
||||
variable. Everything else uses built-in defaults, so to change any
|
||||
other setting mount your own file over `/etc/pixa/config.yml` (see
|
||||
`config.example.yml` for the full set of keys).
|
||||
|
||||
## Rationale
|
||||
|
||||
Image-heavy web applications need a fast, caching reverse proxy that
|
||||
|
||||
@@ -1,27 +1,21 @@
|
||||
# Workflow
|
||||
|
||||
* branch per issue from `next`
|
||||
* branch (from `main`)
|
||||
* do the work in Next Step
|
||||
* move Next Step to the top of Completed Steps
|
||||
* move the top item of Future Steps into Next Step
|
||||
* commit (`TODO.md` changes in the same commit as the work)
|
||||
* open a PR based on `next`
|
||||
* an independent reviewer who did not write the change gates it
|
||||
* the manager squash-merges the PR into `next` once review passes
|
||||
* `next` stays green and mergeable to `main` at any time; only the owner
|
||||
merges `next` into `main`, via the single milestone PR
|
||||
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||
* push
|
||||
|
||||
# Status
|
||||
|
||||
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work
|
||||
lands on `next`, and `main` receives only the milestone PR that the
|
||||
owner merges. `next` is at the canonical `golangci-lint` v2.12.2 config
|
||||
and is green. Recent work extracted the internal/magic,
|
||||
pre-1.0. No git tags exist. Recent work extracted the internal/magic,
|
||||
internal/allowlist, internal/httpfetcher, and internal/signature
|
||||
packages. The gosec findings from the 2026-07-06 survey are resolved.
|
||||
The disk cache is now size-bounded with LRU eviction
|
||||
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
|
||||
packages. The gosec findings from the 2026-07-06 survey are resolved
|
||||
and `make check` is green on main. The disk cache is now size-bounded
|
||||
with LRU eviction (`cache_max_bytes`), closing the unbounded disk
|
||||
growth DoS vector.
|
||||
|
||||
# Next Step
|
||||
|
||||
@@ -29,25 +23,6 @@ P1: implement blocked networks configuration to extend SSRF protection
|
||||
|
||||
# Completed Steps
|
||||
|
||||
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
|
||||
route and the token generator (closes #62): added a shared
|
||||
`ValidateImageRequest` in `internal/imgcache` enforcing the
|
||||
`MaxDimension` bound and `ValidateFitMode`, applied by both the
|
||||
`/v1/image/` and `/v1/e/` routes, so an over-limit size or an unknown
|
||||
fit mode is a 400 rather than an out-of-memory or a 500 from the
|
||||
processor; the URL generator now checks every numeric form field and
|
||||
rejects a non-numeric or out-of-range `width`, `height`, `quality`,
|
||||
or `ttl` with a 400 naming the field instead of coercing it to `0`,
|
||||
and `width`/`height` are bounds-checked so an unusable token cannot be
|
||||
minted
|
||||
- 2026-09-21 http.Server hardening (closes #92): added
|
||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||
existing timeouts and wired them onto the server; added a `LimitBody`
|
||||
middleware capping the two form POST bodies (`POST /`, `POST /generate`)
|
||||
at `MaxFormBytes` (1 MiB) and returning 413, applied ahead of the CSRF
|
||||
middleware so an oversized body is refused as 413 rather than being read
|
||||
as a missing CSRF token (403); left `WriteTimeout` at 60s unchanged
|
||||
- 2026-08-07 update golangci-lint to v2.12.2 with the canonical
|
||||
`.golangci.yml` (v2 schema, `default: all` minus six disabled
|
||||
linters, `lll` 88, tests included): bumped the pinned
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Pixa configuration baked into the Docker image.
|
||||
#
|
||||
# The signing key is read from the PIXA_SIGNING_KEY environment
|
||||
# variable; startup aborts naming it when it is unset. Every other key
|
||||
# is omitted so its default applies. Operators who need more (an
|
||||
# allowlist, metrics, and so on) mount their own file over
|
||||
# /etc/pixa/config.yml.
|
||||
|
||||
signing_key: "${ENV:PIXA_SIGNING_KEY}"
|
||||
state_dir: /var/lib/pixa
|
||||
port: 8080
|
||||
@@ -44,12 +44,6 @@ const (
|
||||
keyCacheMaxBytes = "cache_max_bytes"
|
||||
)
|
||||
|
||||
// placeholderSigningKey is the dummy signing_key shipped in
|
||||
// config.example.yml. It is 45 characters, so it passes the length
|
||||
// check, but it is public in this repository and must be rejected at
|
||||
// startup so no deployment ever signs URLs with it.
|
||||
const placeholderSigningKey = "CHANGE_ME_generate_with_openssl_rand_base64_32"
|
||||
|
||||
// Static validation errors. Each use site attaches the offending key
|
||||
// and value by wrapping these with fmt.Errorf and %w.
|
||||
var (
|
||||
@@ -67,9 +61,6 @@ var (
|
||||
errPortOutOfRange = errors.New("outside the valid port range")
|
||||
errTooFewConnections = errors.New("must be at least 1")
|
||||
errValueTooShort = errors.New("value too short")
|
||||
errPlaceholderKey = errors.New(
|
||||
"is the placeholder from config.example.yml; " +
|
||||
"generate a real key with: openssl rand -base64 32")
|
||||
errMustBeSetTogether = errors.New("must be set together")
|
||||
errMustNotBeNegative = errors.New("must not be negative")
|
||||
errOverflowsInt64 = errors.New("overflows a 64-bit integer")
|
||||
@@ -350,10 +341,10 @@ func (c *Config) ensureStateDirWritable() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateSigningKey checks that the signing key is present, long
|
||||
// enough, and not the public placeholder from config.example.yml. The
|
||||
// key value itself is never echoed in error messages.
|
||||
func (c *Config) validateSigningKey() error {
|
||||
// validate checks that all required configuration values are set and
|
||||
// that every value is within its valid range.
|
||||
func (c *Config) validate() error {
|
||||
// The signing key value is never echoed in error messages.
|
||||
if c.SigningKey == "" {
|
||||
return fmt.Errorf("config key %q: %w", keySigningKey, errValueRequired)
|
||||
}
|
||||
@@ -365,21 +356,6 @@ func (c *Config) validateSigningKey() error {
|
||||
keySigningKey, errValueTooShort, minKeyLength, len(c.SigningKey))
|
||||
}
|
||||
|
||||
if c.SigningKey == placeholderSigningKey {
|
||||
return fmt.Errorf("config key %q: %w", keySigningKey, errPlaceholderKey)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validate checks that all required configuration values are set and
|
||||
// that every value is within its valid range.
|
||||
func (c *Config) validate() error {
|
||||
err := c.validateSigningKey()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
const maxPort = 65535
|
||||
if c.Port < 1 || c.Port > maxPort {
|
||||
return fmt.Errorf("config key %q: value %d is %w 1-%d",
|
||||
|
||||
@@ -303,11 +303,6 @@ func invalidHostAndCredentialCases() []abortCase {
|
||||
yaml: "signing_key: short\n",
|
||||
wantErrSubstrings: []string{keySigningKey},
|
||||
},
|
||||
{
|
||||
name: "signing_key is the documented placeholder",
|
||||
yaml: "signing_key: " + placeholderSigningKey + "\n",
|
||||
wantErrSubstrings: []string{keySigningKey},
|
||||
},
|
||||
{
|
||||
name: "signing_key missing",
|
||||
yaml: "port: 8080\n",
|
||||
|
||||
+17
-98
@@ -2,8 +2,6 @@ package handlers
|
||||
|
||||
import (
|
||||
"crypto/subtle"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"net/http"
|
||||
"net/url"
|
||||
@@ -15,11 +13,6 @@ import (
|
||||
"sneak.berlin/go/pixa/internal/templates"
|
||||
)
|
||||
|
||||
// errInvalidFormField reports a generator form field whose value is
|
||||
// non-numeric or out of range. The offending field name is wrapped in so the
|
||||
// response can name it.
|
||||
var errInvalidFormField = errors.New("invalid")
|
||||
|
||||
// HandleRoot serves the login page or generator page based on authentication state.
|
||||
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
@@ -105,26 +98,18 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
// Validate source URL
|
||||
parsed, err := url.Parse(sourceURL)
|
||||
if err != nil || parsed.Host == "" {
|
||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||
http.StatusBadRequest)
|
||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
||||
if err != nil {
|
||||
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
|
||||
|
||||
// Generate encrypted token
|
||||
token, err := s.encGen.Generate(payload)
|
||||
if err != nil {
|
||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||
http.StatusInternalServerError)
|
||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
|
||||
|
||||
return
|
||||
}
|
||||
@@ -152,38 +137,17 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
||||
}
|
||||
|
||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
||||
// non-numeric or out-of-range field, or an unrecognized fit mode, is a client
|
||||
// error naming the offending field, so an unusable token is never minted.
|
||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
|
||||
func buildGeneratePayload(
|
||||
parsed *url.URL, form url.Values,
|
||||
) (*encurl.Payload, time.Time, int, error) {
|
||||
width, err := parseFormDimension(form, "width")
|
||||
if err != nil {
|
||||
return nil, time.Time{}, 0, err
|
||||
}
|
||||
) (*encurl.Payload, time.Time, int) {
|
||||
width, _ := strconv.Atoi(form.Get("width"))
|
||||
height, _ := strconv.Atoi(form.Get("height"))
|
||||
quality, _ := strconv.Atoi(form.Get("quality"))
|
||||
ttl, _ := strconv.Atoi(form.Get("ttl"))
|
||||
|
||||
height, err := parseFormDimension(form, "height")
|
||||
if err != nil {
|
||||
return nil, time.Time{}, 0, err
|
||||
}
|
||||
|
||||
quality, err := parseFormCount(form, "quality", encurl.DefaultQuality)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, 0, err
|
||||
}
|
||||
|
||||
ttl, err := parseFormCount(form, "ttl", 0)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, 0, err
|
||||
}
|
||||
|
||||
fitMode := imgcache.FitMode(form.Get("fit"))
|
||||
|
||||
err = imgcache.ValidateFitMode(fitMode)
|
||||
if err != nil {
|
||||
return nil, time.Time{}, 0,
|
||||
fmt.Errorf("%w: %s", imgcache.ErrInvalidFitMode, form.Get("fit"))
|
||||
if quality <= 0 {
|
||||
quality = 85
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -204,45 +168,11 @@ func buildGeneratePayload(
|
||||
Height: height,
|
||||
Format: imgcache.ImageFormat(form.Get("format")),
|
||||
Quality: quality,
|
||||
FitMode: fitMode,
|
||||
FitMode: imgcache.FitMode(form.Get("fit")),
|
||||
ExpiresAt: expiresAtUnix,
|
||||
}
|
||||
|
||||
return payload, expiresAt, ttl, nil
|
||||
}
|
||||
|
||||
// parseFormDimension reads an optional width or height form field. An empty
|
||||
// value means "original size" (0). A non-numeric, negative, or over-limit
|
||||
// value is rejected with an error naming the field.
|
||||
func parseFormDimension(form url.Values, field string) (int, error) {
|
||||
raw := form.Get(field)
|
||||
if raw == "" {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 || value > imgcache.MaxDimension {
|
||||
return 0, fmt.Errorf("%w %s", errInvalidFormField, field)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// parseFormCount reads an optional non-negative integer form field, returning
|
||||
// def when the field is empty and an error naming the field when the value is
|
||||
// non-numeric or negative.
|
||||
func parseFormCount(form url.Values, field string, def int) (int, error) {
|
||||
raw := form.Get(field)
|
||||
if raw == "" {
|
||||
return def, nil
|
||||
}
|
||||
|
||||
value, err := strconv.Atoi(raw)
|
||||
if err != nil || value < 0 {
|
||||
return 0, fmt.Errorf("%w %s", errInvalidFormField, field)
|
||||
}
|
||||
|
||||
return value, nil
|
||||
return payload, expiresAt, ttl
|
||||
}
|
||||
|
||||
// generatorData holds template data for the generator page.
|
||||
@@ -282,15 +212,6 @@ func (s *Handlers) renderLogin(
|
||||
|
||||
func (s *Handlers) renderGenerator(
|
||||
w http.ResponseWriter, r *http.Request, data *generatorData,
|
||||
) {
|
||||
s.renderGeneratorStatus(w, r, data, http.StatusOK)
|
||||
}
|
||||
|
||||
// renderGeneratorStatus renders the generator page with an explicit HTTP
|
||||
// status. The status is written before the body so both it and the
|
||||
// Content-Type header take effect; a rejected form uses 400.
|
||||
func (s *Handlers) renderGeneratorStatus(
|
||||
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
|
||||
) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
|
||||
@@ -300,19 +221,17 @@ func (s *Handlers) renderGeneratorStatus(
|
||||
|
||||
data.CSRFField = csrfField(r)
|
||||
|
||||
w.WriteHeader(status)
|
||||
|
||||
err := templates.Render(w, "generator.html", data)
|
||||
if err != nil {
|
||||
s.log.Error("failed to render generator template", "error", err)
|
||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Handlers) renderGeneratorWithForm(
|
||||
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||
form url.Values, status int,
|
||||
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
|
||||
) {
|
||||
s.renderGeneratorStatus(w, r, &generatorData{
|
||||
s.renderGenerator(w, r, &generatorData{
|
||||
Error: errorMsg,
|
||||
FormURL: form.Get("url"),
|
||||
FormWidth: form.Get("width"),
|
||||
@@ -321,7 +240,7 @@ func (s *Handlers) renderGeneratorWithForm(
|
||||
FormQuality: form.Get("quality"),
|
||||
FormFit: form.Get("fit"),
|
||||
FormTTL: form.Get("ttl"),
|
||||
}, status)
|
||||
})
|
||||
}
|
||||
|
||||
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||
|
||||
@@ -1,66 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"maps"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// generatePost submits the /generate form with a valid session and CSRF token
|
||||
// plus the caller's extra fields, returning the recorder.
|
||||
func generatePost(
|
||||
t *testing.T, extra url.Values,
|
||||
) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
h, srv := newCSRFTestRouter(t)
|
||||
|
||||
sessionCookie := newSessionCookie(t, h)
|
||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||
cookies = append(cookies, sessionCookie)
|
||||
|
||||
form := url.Values{
|
||||
sourceURLField: {testSourceURL},
|
||||
csrfTokenField: {token},
|
||||
}
|
||||
maps.Copy(form, extra)
|
||||
|
||||
return postForm(srv, "/generate", cookies, form)
|
||||
}
|
||||
|
||||
// TestGeneratePostRejectsNonNumericWidth verifies that a non-numeric width is
|
||||
// rejected with 400 naming the field rather than being coerced to 0 and
|
||||
// minting a 0-width token.
|
||||
func TestGeneratePostRejectsNonNumericWidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := generatePost(t, url.Values{"width": {"abc"}})
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||
t.Error("a token was generated for non-numeric width")
|
||||
}
|
||||
}
|
||||
|
||||
// TestGeneratePostRejectsOverLimitWidth verifies that a width beyond
|
||||
// MaxDimension is rejected at generation time so an unusable token cannot be
|
||||
// minted.
|
||||
func TestGeneratePostRejectsOverLimitWidth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rec := generatePost(t, url.Values{"width": {"100000"}})
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
if strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||
t.Error("a token was generated for an over-limit width")
|
||||
}
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// MaxFormBytes bounds the request body accepted on the HTML form POST
|
||||
// routes (POST / and POST /generate). The forms carry a handful of short
|
||||
// fields, so 1 MiB is generous while making the bound explicit rather than
|
||||
// resting on ParseForm's incidental 10 MB cap.
|
||||
const MaxFormBytes = 1 << 20 // 1 MiB
|
||||
|
||||
// LimitBody returns middleware that caps the request body on POST requests
|
||||
// at maxBytes and rejects an oversized body with 413 Request Entity Too
|
||||
// Large.
|
||||
//
|
||||
// It parses the form here, before the CSRF middleware reads the token from
|
||||
// it. The CSRF middleware reads the token with PostFormValue, which
|
||||
// swallows a parse error, so if the body were only capped there an
|
||||
// oversized body would read as a missing token and be refused as 403. By
|
||||
// parsing under the cap first, an oversized body is refused as 413. A
|
||||
// successful parse is cached on the request, so the CSRF check and the
|
||||
// handler reuse it rather than reading the body again.
|
||||
func (s *Handlers) LimitBody(maxBytes int64) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
r.Body = http.MaxBytesReader(w, r.Body, maxBytes)
|
||||
|
||||
err := r.ParseForm()
|
||||
|
||||
var tooLarge *http.MaxBytesError
|
||||
if errors.As(err, &tooLarge) {
|
||||
http.Error(w, "Request body too large",
|
||||
http.StatusRequestEntityTooLarge)
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/session"
|
||||
)
|
||||
|
||||
// Form field names and a throwaway source image URL for the body-limit
|
||||
// tests.
|
||||
const (
|
||||
sourceURLField = "url"
|
||||
testSourceURL = "https://example.com/a.jpg"
|
||||
)
|
||||
|
||||
// newBodyLimitTestRouter mirrors the production wiring for the form POST
|
||||
// routes (see server.SetupRoutes): LimitBody sits in front of the CSRF
|
||||
// middleware, which sits in front of the handlers. maxBytes is the body
|
||||
// cap under test, so a test can trip the limit with a small body.
|
||||
func newBodyLimitTestRouter(
|
||||
t *testing.T, maxBytes int64,
|
||||
) (*Handlers, http.Handler) {
|
||||
t.Helper()
|
||||
|
||||
cfg := &config.Config{SigningKey: testSigningKey, Debug: true}
|
||||
|
||||
sessMgr, err := session.NewManager(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("session.NewManager() error = %v", err)
|
||||
}
|
||||
|
||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||
}
|
||||
|
||||
protect, err := newCSRFProtect(testSigningKey, cfg.Debug)
|
||||
if err != nil {
|
||||
t.Fatalf("newCSRFProtect() error = %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
config: cfg,
|
||||
sessMgr: sessMgr,
|
||||
encGen: encGen,
|
||||
csrfProtect: protect,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(h.LimitBody(maxBytes))
|
||||
r.Use(h.CSRF())
|
||||
r.Get("/", h.HandleRoot())
|
||||
r.Post("/", h.HandleRoot())
|
||||
r.Post("/generate", h.HandleGenerateURL())
|
||||
})
|
||||
|
||||
return h, r
|
||||
}
|
||||
|
||||
// TestOversizedLoginPostRejectedBeforeCSRF is the core regression: an
|
||||
// oversized POST / carrying an otherwise valid CSRF cookie and token must
|
||||
// be rejected with 413. If the body limit ran after CSRF, the truncated
|
||||
// body would read as a missing token and return 403; if it ran after the
|
||||
// handler, a valid token would return 303. Getting 413 proves the limit
|
||||
// fires before CSRF parses the form.
|
||||
func TestOversizedLoginPostRejectedBeforeCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newBodyLimitTestRouter(t, 16)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/", cookies, url.Values{
|
||||
loginKeyField: {testSigningKey},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized POST / status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOversizedGeneratePostRejectedBeforeCSRF is the same regression for
|
||||
// POST /generate, which also parses a form behind CSRF.
|
||||
func TestOversizedGeneratePostRejectedBeforeCSRF(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newBodyLimitTestRouter(t, 16)
|
||||
|
||||
sessionCookie := newSessionCookie(t, h)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||
cookies = append(cookies, sessionCookie)
|
||||
|
||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||
sourceURLField: {testSourceURL},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusRequestEntityTooLarge {
|
||||
t.Errorf("oversized POST /generate status = %d, want %d",
|
||||
rec.Code, http.StatusRequestEntityTooLarge)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithinLimitLoginPostSucceeds verifies the limit does not disturb a
|
||||
// normal request: under the production cap, a valid login still parses and
|
||||
// establishes a session (303). This guards against the body limit
|
||||
// consuming or corrupting the form the CSRF check and handler depend on.
|
||||
func TestWithinLimitLoginPostSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, nil)
|
||||
|
||||
rec := postForm(srv, "/", cookies, url.Values{
|
||||
loginKeyField: {testSigningKey},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusSeeOther {
|
||||
t.Fatalf("within-limit POST / status = %d, want %d",
|
||||
rec.Code, http.StatusSeeOther)
|
||||
}
|
||||
|
||||
var authed bool
|
||||
|
||||
for _, c := range rec.Result().Cookies() {
|
||||
if c.Name == session.CookieName && c.Value != "" {
|
||||
authed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !authed {
|
||||
t.Error("within-limit valid login did not set a session cookie")
|
||||
}
|
||||
}
|
||||
|
||||
// TestWithinLimitGeneratePostSucceeds is the same non-regression check for
|
||||
// POST /generate.
|
||||
func TestWithinLimitGeneratePostSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
h, srv := newBodyLimitTestRouter(t, MaxFormBytes)
|
||||
|
||||
sessionCookie := newSessionCookie(t, h)
|
||||
|
||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
||||
cookies = append(cookies, sessionCookie)
|
||||
|
||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
||||
sourceURLField: {testSourceURL},
|
||||
"format": {"jpeg"},
|
||||
csrfTokenField: {token},
|
||||
})
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("within-limit POST /generate status = %d, want %d",
|
||||
rec.Code, http.StatusOK)
|
||||
}
|
||||
|
||||
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
||||
t.Error("within-limit generate response did not contain a generated URL")
|
||||
}
|
||||
}
|
||||
@@ -110,6 +110,13 @@ func (s *Handlers) parseImageRequest(
|
||||
|
||||
if fit := query.Get("fit"); fit != "" {
|
||||
req.FitMode = imgcache.FitMode(fit)
|
||||
|
||||
fitErr := imgcache.ValidateFitMode(req.FitMode)
|
||||
if fitErr != nil {
|
||||
s.respondError(w, "invalid fit mode: "+fit, http.StatusBadRequest)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
// Default quality if not set
|
||||
@@ -122,18 +129,6 @@ func (s *Handlers) parseImageRequest(
|
||||
req.FitMode = imgcache.FitCover
|
||||
}
|
||||
|
||||
// Enforce dimension and fit-mode bounds, shared with the encrypted-URL
|
||||
// route. Dimensions are already bounded by the path parser above; this
|
||||
// also rejects an unrecognized fit mode with 400 instead of letting it
|
||||
// reach the processor as a 500.
|
||||
err = imgcache.ValidateImageRequest(req)
|
||||
if err != nil {
|
||||
s.respondError(w, "invalid image request: "+err.Error(),
|
||||
http.StatusBadRequest)
|
||||
|
||||
return nil, false
|
||||
}
|
||||
|
||||
return req, true
|
||||
}
|
||||
|
||||
|
||||
@@ -50,19 +50,6 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
||||
// Convert payload to ImageRequest
|
||||
req := payload.ToImageRequest()
|
||||
|
||||
// Apply the same dimension and fit-mode bounds as the plain image
|
||||
// route: a sealed payload is trusted for its origin, not for staying
|
||||
// within limits, so an over-limit size or unknown fit mode is a 400
|
||||
// here rather than an out-of-memory or a 500 from the processor.
|
||||
err = imgcache.ValidateImageRequest(req)
|
||||
if err != nil {
|
||||
s.log.Debug("encrypted URL failed validation", "error", err)
|
||||
s.respondError(w, "invalid encrypted URL: "+err.Error(),
|
||||
http.StatusBadRequest)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Log the request
|
||||
s.log.Debug("encrypted image request",
|
||||
"host", req.SourceHost,
|
||||
|
||||
@@ -1,98 +0,0 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/encurl"
|
||||
"sneak.berlin/go/pixa/internal/imgcache"
|
||||
)
|
||||
|
||||
// newEncTestServer builds a router serving the encrypted-URL route with a
|
||||
// generator seeded by the shared test signing key. The image service is left
|
||||
// nil: these tests exercise validation that rejects a token before any image
|
||||
// is fetched, so the handler must never reach the service.
|
||||
func newEncTestServer(t *testing.T) (*encurl.Generator, http.Handler) {
|
||||
t.Helper()
|
||||
|
||||
encGen, err := encurl.NewGenerator(testSigningKey)
|
||||
if err != nil {
|
||||
t.Fatalf("encurl.NewGenerator() error = %v", err)
|
||||
}
|
||||
|
||||
h := &Handlers{
|
||||
log: slog.New(slog.DiscardHandler),
|
||||
encGen: encGen,
|
||||
}
|
||||
|
||||
r := chi.NewRouter()
|
||||
r.Get("/v1/e/{token}/*", h.HandleImageEnc())
|
||||
|
||||
return encGen, r
|
||||
}
|
||||
|
||||
// getEncToken issues a GET for the given token and returns the recorder.
|
||||
func getEncToken(srv http.Handler, token string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequestWithContext(
|
||||
context.Background(), http.MethodGet, "/v1/e/"+token+"/img.jpg", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
srv.ServeHTTP(rec, req)
|
||||
|
||||
return rec
|
||||
}
|
||||
|
||||
// TestHandleImageEnc_OverLimitDimension_Returns400 verifies that a decrypted
|
||||
// token requesting a dimension beyond MaxDimension is rejected with 400
|
||||
// instead of reaching the image processor and libvips.
|
||||
func TestHandleImageEnc_OverLimitDimension_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
encGen, srv := newEncTestServer(t)
|
||||
|
||||
token, err := encGen.Generate(&encurl.Payload{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photo.jpg",
|
||||
Width: 100000,
|
||||
Height: 100000,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
rec := getEncToken(srv, token)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandleImageEnc_InvalidFitMode_Returns400 verifies that a decrypted token
|
||||
// carrying an unrecognized fit mode is rejected with 400 rather than surfacing
|
||||
// as a 500 from the image processor's default branch.
|
||||
func TestHandleImageEnc_InvalidFitMode_Returns400(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
encGen, srv := newEncTestServer(t)
|
||||
|
||||
token, err := encGen.Generate(&encurl.Payload{
|
||||
SourceHost: "cdn.example.com",
|
||||
SourcePath: "/photo.jpg",
|
||||
Width: 800,
|
||||
Height: 600,
|
||||
FitMode: imgcache.FitMode("bogus"),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Generate() error = %v", err)
|
||||
}
|
||||
|
||||
rec := getEncToken(srv, token)
|
||||
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||
}
|
||||
}
|
||||
@@ -1,421 +0,0 @@
|
||||
package httpfetcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testPublicHost is a TEST-NET-1 (RFC 5737) literal. isPrivateIP treats it as
|
||||
// public, so validateURL and the redirect check accept it with no DNS lookup,
|
||||
// while the recording dialer routes it to the local httptest server. The
|
||||
// address is reserved for documentation and is never routed on the network.
|
||||
const testPublicHost = "192.0.2.10"
|
||||
|
||||
// imagePayload is the body served by the fake upstream's image route.
|
||||
const imagePayload = "fake-jpeg-bytes"
|
||||
|
||||
// errUnexpectedDial reports a dial to any host other than testPublicHost, which
|
||||
// would mean SSRF protection let a forbidden target reach the transport.
|
||||
var errUnexpectedDial = errors.New("unexpected dial target")
|
||||
|
||||
// upstreamURL builds a fetch URL on the fake public host for the given path.
|
||||
func upstreamURL(path string) string {
|
||||
return "http://" + testPublicHost + path
|
||||
}
|
||||
|
||||
// recordingDialer records every address the transport asks it to dial and
|
||||
// routes connections for testPublicHost to a real local server, so the SSRF
|
||||
// checks run against a public-looking host while bytes go to httptest.
|
||||
type recordingDialer struct {
|
||||
target string
|
||||
|
||||
mu sync.Mutex
|
||||
dialed []string
|
||||
}
|
||||
|
||||
func (d *recordingDialer) dialContext(
|
||||
ctx context.Context,
|
||||
network, addr string,
|
||||
) (net.Conn, error) {
|
||||
d.mu.Lock()
|
||||
d.dialed = append(d.dialed, addr)
|
||||
d.mu.Unlock()
|
||||
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if host != testPublicHost {
|
||||
return nil, fmt.Errorf("%w: %s", errUnexpectedDial, addr)
|
||||
}
|
||||
|
||||
var dialer net.Dialer
|
||||
|
||||
return dialer.DialContext(ctx, network, d.target)
|
||||
}
|
||||
|
||||
// dialedAddrs returns a copy of the addresses the dialer was asked to reach.
|
||||
func (d *recordingDialer) dialedAddrs() []string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
return slices.Clone(d.dialed)
|
||||
}
|
||||
|
||||
// startUpstream launches a fake upstream with the routes the fetch tests
|
||||
// exercise and stops it when the test finishes.
|
||||
func startUpstream(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/image", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", contentTypeJPEG)
|
||||
_, _ = io.WriteString(w, imagePayload)
|
||||
})
|
||||
mux.HandleFunc("/status/500", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
})
|
||||
mux.HandleFunc("/html", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = io.WriteString(w, "<html></html>")
|
||||
})
|
||||
mux.HandleFunc("/redirect/private", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "http://169.254.169.254/latest/meta-data/", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/redirect/public", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/image", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/redirect/chain", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/redirect/hop", http.StatusFound)
|
||||
})
|
||||
mux.HandleFunc("/redirect/hop", func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Redirect(w, r, "/image", http.StatusFound)
|
||||
})
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
t.Cleanup(srv.Close)
|
||||
|
||||
return srv
|
||||
}
|
||||
|
||||
// newServerFetcher builds a fetcher whose transport routes testPublicHost to
|
||||
// srv, leaving the real SSRF validation and redirect checks in place.
|
||||
func newServerFetcher(
|
||||
t *testing.T,
|
||||
srv *httptest.Server,
|
||||
cfg *Config,
|
||||
) (*HTTPFetcher, *recordingDialer) {
|
||||
t.Helper()
|
||||
|
||||
if cfg == nil {
|
||||
cfg = DefaultConfig()
|
||||
}
|
||||
|
||||
cfg.AllowHTTP = true
|
||||
|
||||
f := New(cfg)
|
||||
|
||||
transport, ok := f.client.Transport.(*http.Transport)
|
||||
if !ok {
|
||||
t.Fatalf("transport is %T, want *http.Transport", f.client.Transport)
|
||||
}
|
||||
|
||||
dialer := &recordingDialer{target: srv.Listener.Addr().String()}
|
||||
transport.DialContext = dialer.dialContext
|
||||
|
||||
return f, dialer
|
||||
}
|
||||
|
||||
// testContext returns a context cancelled when the test ends, bounding any
|
||||
// fetch that would otherwise block on a leaked semaphore slot.
|
||||
func testContext(t *testing.T) context.Context {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
t.Cleanup(cancel)
|
||||
|
||||
return ctx
|
||||
}
|
||||
|
||||
// fetchImage fetches path from the fake upstream and fails on error.
|
||||
func fetchImage(t *testing.T, f *HTTPFetcher, path string) *FetchResult {
|
||||
t.Helper()
|
||||
|
||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||
if err != nil {
|
||||
t.Fatalf("Fetch(%s) error = %v", path, err)
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
||||
|
||||
// fetchExpectError fetches path and fails unless Fetch returns an error.
|
||||
func fetchExpectError(t *testing.T, f *HTTPFetcher, path string) error {
|
||||
t.Helper()
|
||||
|
||||
res, err := f.Fetch(testContext(t), upstreamURL(path))
|
||||
if err == nil {
|
||||
_ = res.Content.Close()
|
||||
|
||||
t.Fatalf("Fetch(%s) = nil error, want an error", path)
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// fetchBody fetches path and returns the fully read, closed response body.
|
||||
func fetchBody(t *testing.T, f *HTTPFetcher, path string) string {
|
||||
t.Helper()
|
||||
|
||||
res := fetchImage(t, f, path)
|
||||
defer func() { _ = res.Content.Close() }()
|
||||
|
||||
data, err := io.ReadAll(res.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
return string(data)
|
||||
}
|
||||
|
||||
// semLen reports how many per-host semaphore slots are currently held.
|
||||
func semLen(f *HTTPFetcher, host string) int {
|
||||
return len(f.getHostSemaphore(host))
|
||||
}
|
||||
|
||||
func TestFetchRedirectToPrivateIPBlocked(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
f, dialer := newServerFetcher(t, srv, nil)
|
||||
|
||||
_, err := f.Fetch(testContext(t), upstreamURL("/redirect/private"))
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Fatalf("Fetch() error = %v, want ErrSSRFBlocked", err)
|
||||
}
|
||||
|
||||
for _, addr := range dialer.dialedAddrs() {
|
||||
if strings.Contains(addr, "169.254.169.254") {
|
||||
t.Errorf("dialer connected to the private redirect target: %s", addr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRedirectToPublicSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
f, _ := newServerFetcher(t, srv, nil)
|
||||
|
||||
if body := fetchBody(t, f, "/redirect/public"); body != imagePayload {
|
||||
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRedirectChainSucceeds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
f, _ := newServerFetcher(t, srv, nil)
|
||||
|
||||
if body := fetchBody(t, f, "/redirect/chain"); body != imagePayload {
|
||||
t.Errorf("body = %q, want %q", body, imagePayload)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsNon2xx(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
f, _ := newServerFetcher(t, srv, nil)
|
||||
|
||||
err := fetchExpectError(t, f, "/status/500")
|
||||
if !errors.Is(err, ErrUpstreamError) {
|
||||
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRejectsDisallowedContentType(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
f, _ := newServerFetcher(t, srv, nil)
|
||||
|
||||
err := fetchExpectError(t, f, "/html")
|
||||
if !errors.Is(err, ErrInvalidContentType) {
|
||||
t.Fatalf("Fetch() error = %v, want ErrInvalidContentType", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchMaxResponseSizeEnforced(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxResponseSize = 8
|
||||
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
|
||||
res := fetchImage(t, f, "/image")
|
||||
defer func() { _ = res.Content.Close() }()
|
||||
|
||||
data, err := io.ReadAll(res.Content)
|
||||
if !errors.Is(err, ErrResponseTooLarge) {
|
||||
t.Fatalf("read error = %v, want ErrResponseTooLarge", err)
|
||||
}
|
||||
|
||||
if int64(len(data)) > cfg.MaxResponseSize {
|
||||
t.Errorf("read %d bytes, exceeds limit %d", len(data), cfg.MaxResponseSize)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchSemaphoreReleasedOnError(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
srv := startUpstream(t)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxConnectionsPerHost = 1
|
||||
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
|
||||
err := fetchExpectError(t, f, "/status/500")
|
||||
if !errors.Is(err, ErrUpstreamError) {
|
||||
t.Fatalf("Fetch() error = %v, want ErrUpstreamError", err)
|
||||
}
|
||||
|
||||
if held := semLen(f, testPublicHost); held != 0 {
|
||||
t.Fatalf("semaphore slot leaked after error: %d held", held)
|
||||
}
|
||||
|
||||
// One slot per host: this fetch proceeds only if the slot was released.
|
||||
res := fetchImage(t, f, "/image")
|
||||
_ = res.Content.Close()
|
||||
}
|
||||
|
||||
// assertSlotReleasedByClose fetches an image over a one-slot host, hands the
|
||||
// open result to consume, and asserts the slot is held before and freed after,
|
||||
// then that a follow-up fetch can still acquire it.
|
||||
func assertSlotReleasedByClose(
|
||||
t *testing.T,
|
||||
consume func(*testing.T, *FetchResult),
|
||||
) {
|
||||
t.Helper()
|
||||
|
||||
srv := startUpstream(t)
|
||||
|
||||
cfg := DefaultConfig()
|
||||
cfg.MaxConnectionsPerHost = 1
|
||||
|
||||
f, _ := newServerFetcher(t, srv, cfg)
|
||||
|
||||
res := fetchImage(t, f, "/image")
|
||||
if held := semLen(f, testPublicHost); held != 1 {
|
||||
t.Fatalf("slot not held while body is open: %d held", held)
|
||||
}
|
||||
|
||||
consume(t, res)
|
||||
|
||||
if held := semLen(f, testPublicHost); held != 0 {
|
||||
t.Fatalf("slot not released after close: %d held", held)
|
||||
}
|
||||
|
||||
next := fetchImage(t, f, "/image")
|
||||
_ = next.Content.Close()
|
||||
}
|
||||
|
||||
func TestFetchSemaphoreReleasedOnBodyClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
|
||||
t.Helper()
|
||||
|
||||
_, err := io.ReadAll(res.Content)
|
||||
if err != nil {
|
||||
t.Fatalf("read body: %v", err)
|
||||
}
|
||||
|
||||
err = res.Content.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close body: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFetchSemaphoreReleasedOnPartialReadClose(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
assertSlotReleasedByClose(t, func(t *testing.T, res *FetchResult) {
|
||||
t.Helper()
|
||||
|
||||
buf := make([]byte, 1)
|
||||
|
||||
_, err := res.Content.Read(buf)
|
||||
if err != nil {
|
||||
t.Fatalf("partial read: %v", err)
|
||||
}
|
||||
|
||||
err = res.Content.Close()
|
||||
if err != nil {
|
||||
t.Fatalf("close body: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// The dial-time re-resolution in ssrfSafeDialer is what closes the DNS
|
||||
// rebinding window: even if validateURL saw a public answer earlier, the
|
||||
// dialer independently re-checks the address it is about to connect to. A full
|
||||
// rebinding simulation (a resolver returning public, then private) would mean
|
||||
// replacing the global net.DefaultResolver with a fake DNS server, which is
|
||||
// heavyweight and unsafe to mutate under parallel -race tests. The property is
|
||||
// proven directly here instead: the dialer rejects a private target outright,
|
||||
// which is exactly the check that fires when a validated host later resolves
|
||||
// to a private address.
|
||||
func TestSSRFSafeDialerBlocksPrivateTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, addr := range []string{
|
||||
"169.254.169.254:80", // link-local (cloud metadata)
|
||||
"127.0.0.1:80", // loopback
|
||||
"10.0.0.5:80", // RFC 1918 private
|
||||
} {
|
||||
t.Run(addr, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := ssrfSafeDialer(context.Background(), "tcp", addr)
|
||||
if !errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("ssrfSafeDialer(%q) = %v, want ErrSSRFBlocked", addr, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSSRFSafeDialerAllowsPublicTarget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// A cancelled context makes the dial fail immediately without touching the
|
||||
// network; the point is only that a public literal is not SSRF-blocked.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
|
||||
_, err := ssrfSafeDialer(ctx, "tcp", testPublicHost+":80")
|
||||
if err == nil {
|
||||
t.Fatal("expected a dial error for an unreachable public target")
|
||||
}
|
||||
|
||||
if errors.Is(err, ErrSSRFBlocked) {
|
||||
t.Errorf("public target was SSRF-blocked: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -59,23 +59,6 @@ func ValidateFitMode(fit FitMode) error {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateImageRequest checks that a request's dimensions are within
|
||||
// MaxDimension and its fit mode is recognized. Both the plain /v1/image/
|
||||
// route and the encrypted /v1/e/ route validate through this function so a
|
||||
// request from either source enforces identical bounds, regardless of how it
|
||||
// was constructed. A width or height of 0 means "original size" and is valid.
|
||||
func ValidateImageRequest(req *ImageRequest) error {
|
||||
if req.Size.Width < 0 || req.Size.Height < 0 {
|
||||
return ErrInvalidSize
|
||||
}
|
||||
|
||||
if req.Size.Width > MaxDimension || req.Size.Height > MaxDimension {
|
||||
return ErrDimensionTooLarge
|
||||
}
|
||||
|
||||
return ValidateFitMode(req.FitMode)
|
||||
}
|
||||
|
||||
// ImageRequest represents a request for a processed image
|
||||
type ImageRequest struct {
|
||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
package imgcache
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateImageRequest(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
req ImageRequest
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "within bounds",
|
||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: FitCover},
|
||||
},
|
||||
{
|
||||
name: "original size and empty fit",
|
||||
req: ImageRequest{Size: Size{Width: 0, Height: 0}},
|
||||
},
|
||||
{
|
||||
name: "width over limit",
|
||||
req: ImageRequest{Size: Size{Width: MaxDimension + 1, Height: 600}},
|
||||
wantErr: ErrDimensionTooLarge,
|
||||
},
|
||||
{
|
||||
name: "height over limit",
|
||||
req: ImageRequest{Size: Size{Width: 800, Height: MaxDimension + 1}},
|
||||
wantErr: ErrDimensionTooLarge,
|
||||
},
|
||||
{
|
||||
name: "negative width",
|
||||
req: ImageRequest{Size: Size{Width: -1, Height: 600}},
|
||||
wantErr: ErrInvalidSize,
|
||||
},
|
||||
{
|
||||
name: "invalid fit mode",
|
||||
req: ImageRequest{Size: Size{Width: 800, Height: 600}, FitMode: "bogus"},
|
||||
wantErr: ErrInvalidFitMode,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := ValidateImageRequest(&tt.req)
|
||||
if tt.wantErr == nil {
|
||||
if err != nil {
|
||||
t.Fatalf("ValidateImageRequest() error = %v, want nil", err)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("ValidateImageRequest() error = %v, want %v", err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -21,33 +21,6 @@ import (
|
||||
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
|
||||
const CORSMaxAgeSeconds = 86400
|
||||
|
||||
// HSTSValue is the Strict-Transport-Security header value: one year with
|
||||
// includeSubDomains. Emitted unconditionally even though pixa listens plain
|
||||
// HTTP behind a TLS-terminating proxy; browsers ignore an HSTS header received
|
||||
// over plaintext (RFC 6797 section 8.1), so it never lies about the connection,
|
||||
// and emitting it here avoids trusting a forwarded-proto header.
|
||||
const HSTSValue = "max-age=31536000; includeSubDomains"
|
||||
|
||||
// ContentSecurityPolicyValue is the Content-Security-Policy header value.
|
||||
// default-src 'self' is the baseline and frame-ancestors 'none' is the primary
|
||||
// clickjacking control. 'unsafe-inline' is required in script-src and style-src
|
||||
// because the served templates carry inline onclick handlers (generator page)
|
||||
// and the bundled Tailwind asset injects a runtime <style> element; dropping it
|
||||
// needs template changes outside this issue's scope.
|
||||
const ContentSecurityPolicyValue = "default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'"
|
||||
|
||||
// PermissionsPolicyValue is the Permissions-Policy header value. Every listed
|
||||
// feature is denied because pixa uses none of them.
|
||||
const PermissionsPolicyValue = "accelerometer=(), autoplay=(), camera=(), " +
|
||||
"display-capture=(), geolocation=(), gyroscope=(), magnetometer=(), " +
|
||||
"microphone=(), payment=(), usb=()"
|
||||
|
||||
// Params defines dependencies for Middleware.
|
||||
type Params struct {
|
||||
fx.In
|
||||
@@ -191,16 +164,6 @@ func (s *Middleware) SecurityHeaders() func(http.Handler) http.Handler {
|
||||
// Disable XSS filtering (modern browsers don't need it, can cause issues)
|
||||
w.Header().Set("X-XSS-Protection", "0")
|
||||
|
||||
// Force HTTPS on future visits (ignored by browsers over plaintext)
|
||||
w.Header().Set("Strict-Transport-Security", HSTSValue)
|
||||
|
||||
// Restrict content sources; frame-ancestors is the primary
|
||||
// clickjacking control, X-Frame-Options the legacy fallback
|
||||
w.Header().Set("Content-Security-Policy", ContentSecurityPolicyValue)
|
||||
|
||||
// Deny browser features pixa does not use
|
||||
w.Header().Set("Permissions-Policy", PermissionsPolicyValue)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -56,61 +56,6 @@ func TestSecurityHeaders(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_PolicyHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cfg := &config.Config{}
|
||||
mw := &Middleware{
|
||||
log: slog.Default(),
|
||||
config: cfg,
|
||||
}
|
||||
|
||||
testHandler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
|
||||
handler := mw.SecurityHeaders()(testHandler)
|
||||
|
||||
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/test", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
tests := []struct {
|
||||
header string
|
||||
want string
|
||||
}{
|
||||
{"Strict-Transport-Security", "max-age=31536000; includeSubDomains"},
|
||||
{
|
||||
"Content-Security-Policy",
|
||||
"default-src 'self'; " +
|
||||
"script-src 'self' 'unsafe-inline'; " +
|
||||
"style-src 'self' 'unsafe-inline'; " +
|
||||
"object-src 'none'; " +
|
||||
"base-uri 'self'; " +
|
||||
"form-action 'self'; " +
|
||||
"frame-ancestors 'none'",
|
||||
},
|
||||
{
|
||||
"Permissions-Policy",
|
||||
"accelerometer=(), autoplay=(), camera=(), " +
|
||||
"display-capture=(), geolocation=(), gyroscope=(), " +
|
||||
"magnetometer=(), microphone=(), payment=(), usb=()",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.header, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
got := rec.Header().Get(tt.header)
|
||||
if got != tt.want {
|
||||
t.Errorf("%s = %q, want %q", tt.header, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
+11
-27
@@ -9,40 +9,24 @@ import (
|
||||
|
||||
// HTTP server configuration constants.
|
||||
const (
|
||||
HTTPReadTimeout = 30 * time.Second
|
||||
// HTTPReadHeaderTimeout bounds the request-header read on its own,
|
||||
// short, so a slowloris client dribbling headers is dropped well
|
||||
// before it ties up a connection for the whole ReadTimeout window.
|
||||
HTTPReadHeaderTimeout = 10 * time.Second
|
||||
HTTPWriteTimeout = 60 * time.Second
|
||||
// HTTPIdleTimeout bounds how long an idle keep-alive connection is
|
||||
// held open, so idle connections cannot accumulate without limit on a
|
||||
// service targeting high concurrency.
|
||||
HTTPIdleTimeout = 120 * time.Second
|
||||
HTTPReadTimeout = 30 * time.Second
|
||||
HTTPWriteTimeout = 60 * time.Second
|
||||
HTTPMaxHeaderBytes = 8 << 10 // 8KB
|
||||
)
|
||||
|
||||
// newHTTPServer builds the http.Server with the hardening timeouts and
|
||||
// limits applied. It is separate from serveUntilShutdown so the
|
||||
// configuration can be asserted in a test without binding a listener.
|
||||
func (s *Server) newHTTPServer() *http.Server {
|
||||
return &http.Server{
|
||||
Addr: fmt.Sprintf(":%d", s.config.Port),
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
ReadHeaderTimeout: HTTPReadHeaderTimeout,
|
||||
WriteTimeout: HTTPWriteTimeout,
|
||||
IdleTimeout: HTTPIdleTimeout,
|
||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||
Handler: s,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) serveUntilShutdown() {
|
||||
s.httpServer = s.newHTTPServer()
|
||||
listenAddr := fmt.Sprintf(":%d", s.config.Port)
|
||||
s.httpServer = &http.Server{
|
||||
Addr: listenAddr,
|
||||
ReadTimeout: HTTPReadTimeout,
|
||||
WriteTimeout: HTTPWriteTimeout,
|
||||
MaxHeaderBytes: HTTPMaxHeaderBytes,
|
||||
Handler: s,
|
||||
}
|
||||
|
||||
s.SetupRoutes()
|
||||
|
||||
s.log.Info("http begin listen", "listenaddr", s.httpServer.Addr)
|
||||
s.log.Info("http begin listen", "listenaddr", listenAddr)
|
||||
|
||||
err := s.httpServer.ListenAndServe()
|
||||
if err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/config"
|
||||
)
|
||||
|
||||
// TestNewHTTPServerTimeouts verifies that the constructed http.Server
|
||||
// carries every hardening timeout wired onto it, including the slowloris
|
||||
// defense (ReadHeaderTimeout) and the keep-alive bound (IdleTimeout). This
|
||||
// guards against a field being defined but never set on the server, so
|
||||
// each assertion compares the server field to its constant.
|
||||
func TestNewHTTPServerTimeouts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
s := &Server{config: &config.Config{Port: 8080}}
|
||||
|
||||
srv := s.newHTTPServer()
|
||||
|
||||
fields := []struct {
|
||||
name string
|
||||
got time.Duration
|
||||
want time.Duration
|
||||
}{
|
||||
{"ReadTimeout", srv.ReadTimeout, HTTPReadTimeout},
|
||||
{"ReadHeaderTimeout", srv.ReadHeaderTimeout, HTTPReadHeaderTimeout},
|
||||
{"WriteTimeout", srv.WriteTimeout, HTTPWriteTimeout},
|
||||
{"IdleTimeout", srv.IdleTimeout, HTTPIdleTimeout},
|
||||
}
|
||||
|
||||
for _, f := range fields {
|
||||
if f.got != f.want {
|
||||
t.Errorf("%s = %v, want %v", f.name, f.got, f.want)
|
||||
}
|
||||
}
|
||||
|
||||
if srv.MaxHeaderBytes != HTTPMaxHeaderBytes {
|
||||
t.Errorf("MaxHeaderBytes = %d, want %d",
|
||||
srv.MaxHeaderBytes, HTTPMaxHeaderBytes)
|
||||
}
|
||||
|
||||
if srv.Handler != s {
|
||||
t.Error("Handler is not the server")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHardeningTimeoutValues pins the intent behind the two new timeouts
|
||||
// without hard-coding brittle exact durations: the header-read phase is
|
||||
// bounded strictly shorter than the whole-request read (the slowloris
|
||||
// dribble), and idle keep-alive connections are bounded rather than held
|
||||
// open forever.
|
||||
func TestHardeningTimeoutValues(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
if HTTPReadHeaderTimeout <= 0 || HTTPReadHeaderTimeout > HTTPReadTimeout {
|
||||
t.Errorf("ReadHeaderTimeout = %v, want positive and <= ReadTimeout %v",
|
||||
HTTPReadHeaderTimeout, HTTPReadTimeout)
|
||||
}
|
||||
|
||||
if HTTPIdleTimeout <= 0 {
|
||||
t.Errorf("IdleTimeout = %v, want positive bound", HTTPIdleTimeout)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/prometheus/client_golang/prometheus/promhttp"
|
||||
|
||||
"sneak.berlin/go/pixa/internal/handlers"
|
||||
"sneak.berlin/go/pixa/internal/static"
|
||||
)
|
||||
|
||||
@@ -47,10 +46,8 @@ func (s *Server) SetupRoutes() {
|
||||
|
||||
// Login/generator UI. The form routes carry CSRF protection; the
|
||||
// token cookie is independent of the session cookie, so it also
|
||||
// covers the login POST, where no session exists yet. LimitBody caps
|
||||
// the POST body ahead of CSRF, which reads its token from that body.
|
||||
// covers the login POST, where no session exists yet.
|
||||
s.router.Group(func(r chi.Router) {
|
||||
r.Use(s.h.LimitBody(handlers.MaxFormBytes))
|
||||
r.Use(s.h.CSRF())
|
||||
r.Get("/", s.h.HandleRoot())
|
||||
r.Post("/", s.h.HandleRoot())
|
||||
|
||||
+1
-5
@@ -17,11 +17,7 @@ run_with_cgo_deps() {
|
||||
main() {
|
||||
cd "$ROOT"
|
||||
echo "Running tests..."
|
||||
# Run without -v first for clean output on success; on failure rerun
|
||||
# with -v for full diagnostics, then exit non-zero (REPO_POLICIES.md
|
||||
# conditional-verbose-rerun pattern). The first run already proved the
|
||||
# tests broken, so the build fails even if the rerun happens to pass.
|
||||
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -cover ./... || { echo '--- Rerunning with -v for details ---'; CGO_ENABLED=1 go test -timeout 30s -race -v ./...; exit 1; }"
|
||||
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -v ./..."
|
||||
}
|
||||
|
||||
main "$@"
|
||||
|
||||
Reference in New Issue
Block a user