main -> prod: first deploy of pixa #147
@@ -95,6 +95,20 @@ exhaustion
|
|||||||
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
(IPv4-mapped forms covered); enforcement stays in the dial-time
|
||||||
re-resolution so the DNS-rebinding window remains closed; documented in
|
re-resolution so the DNS-rebinding window remains closed; documented in
|
||||||
`README.md` and `config.example.yml`.
|
`README.md` and `config.example.yml`.
|
||||||
|
- 2026-09-21 validate dimensions and fit mode on the encrypted-URL
|
||||||
|
route and the token generator (closes #62): `imgcache.ValidateDimension`
|
||||||
|
alone holds the `MaxDimension` bound and is used by the path parser, by
|
||||||
|
the new `ValidateImageRequest` (which also applies `ValidateFitMode`)
|
||||||
|
and by the generator; both the `/v1/image/` and `/v1/e/` routes call
|
||||||
|
`ValidateImageRequest`, 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 answers 400 naming the field for a `width` or `height` that is
|
||||||
|
not a number or fails the shared check, a `quality` that is not a number
|
||||||
|
from 1 to 100, a `ttl` that is not a number from 0 to the largest number
|
||||||
|
of seconds the expiry calculation can hold, or an unknown `fit`; an empty
|
||||||
|
`quality` is 85 and
|
||||||
|
an empty `ttl` never expires; the form's width and height inputs stop at
|
||||||
|
8192
|
||||||
- 2026-09-21 http.Server hardening (closes #92): added
|
- 2026-09-21 http.Server hardening (closes #92): added
|
||||||
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
`HTTPReadHeaderTimeout` (10s, bounds the slowloris header dribble) and
|
||||||
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
`HTTPIdleTimeout` (120s, bounds keep-alive reuse) alongside the
|
||||||
|
|||||||
+132
-17
@@ -1,8 +1,12 @@
|
|||||||
package handlers
|
package handlers
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
"html/template"
|
"html/template"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -14,6 +18,19 @@ import (
|
|||||||
"sneak.berlin/go/pixa/internal/templates"
|
"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")
|
||||||
|
|
||||||
|
// Bounds for the generator's quality and ttl fields. maxTTL is in seconds:
|
||||||
|
// the expiry calculation time.Duration(ttl) * time.Second overflows above it.
|
||||||
|
const (
|
||||||
|
minQuality = 1
|
||||||
|
maxQuality = 100
|
||||||
|
maxTTL = int(math.MaxInt64 / time.Second)
|
||||||
|
)
|
||||||
|
|
||||||
// HandleRoot serves the login page or generator page based on authentication state.
|
// HandleRoot serves the login page or generator page based on authentication state.
|
||||||
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
func (s *Handlers) HandleRoot() http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -101,18 +118,26 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
// Validate source URL
|
// Validate source URL
|
||||||
parsed, err := url.Parse(sourceURL)
|
parsed, err := url.Parse(sourceURL)
|
||||||
if err != nil || parsed.Host == "" {
|
if err != nil || parsed.Host == "" {
|
||||||
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Invalid source URL", r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload, expiresAt, ttl := buildGeneratePayload(parsed, r.Form)
|
payload, expiresAt, ttl, err := buildGeneratePayload(parsed, r.Form)
|
||||||
|
if err != nil {
|
||||||
|
s.renderGeneratorWithForm(w, r, err.Error(), r.Form,
|
||||||
|
http.StatusBadRequest)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Generate encrypted token
|
// Generate encrypted token
|
||||||
token, err := s.encGen.Generate(payload)
|
token, err := s.encGen.Generate(payload)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to generate encrypted URL", "error", err)
|
s.log.Error("failed to generate encrypted URL", "error", err)
|
||||||
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form)
|
s.renderGeneratorWithForm(w, r, "Failed to generate URL", r.Form,
|
||||||
|
http.StatusInternalServerError)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -140,17 +165,40 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildGeneratePayload parses the numeric form fields and assembles the
|
// buildGeneratePayload parses the numeric form fields and assembles the
|
||||||
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0).
|
// encrypted URL payload. ttl=0 means never expires (ExpiresAt stays 0). A
|
||||||
|
// non-numeric or out-of-range width, height, quality or ttl, or an
|
||||||
|
// unrecognized fit mode, is a client error naming the offending field. The
|
||||||
|
// format field is passed through unchecked.
|
||||||
func buildGeneratePayload(
|
func buildGeneratePayload(
|
||||||
parsed *url.URL, form url.Values,
|
parsed *url.URL, form url.Values,
|
||||||
) (*encurl.Payload, time.Time, int) {
|
) (*encurl.Payload, time.Time, int, error) {
|
||||||
width, _ := strconv.Atoi(form.Get("width"))
|
width, err := parseFormDimension(form, "width")
|
||||||
height, _ := strconv.Atoi(form.Get("height"))
|
if err != nil {
|
||||||
quality, _ := strconv.Atoi(form.Get("quality"))
|
return nil, time.Time{}, 0, err
|
||||||
ttl, _ := strconv.Atoi(form.Get("ttl"))
|
}
|
||||||
|
|
||||||
if quality <= 0 {
|
height, err := parseFormDimension(form, "height")
|
||||||
quality = 85
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
quality, err := parseFormInt(form, "quality",
|
||||||
|
encurl.DefaultQuality, minQuality, maxQuality)
|
||||||
|
if err != nil {
|
||||||
|
return nil, time.Time{}, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
ttl, err := parseFormInt(form, "ttl", 0, 0, maxTTL)
|
||||||
|
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"))
|
||||||
}
|
}
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -171,11 +219,57 @@ func buildGeneratePayload(
|
|||||||
Height: height,
|
Height: height,
|
||||||
Format: imgcache.ImageFormat(form.Get("format")),
|
Format: imgcache.ImageFormat(form.Get("format")),
|
||||||
Quality: quality,
|
Quality: quality,
|
||||||
FitMode: imgcache.FitMode(form.Get("fit")),
|
FitMode: fitMode,
|
||||||
ExpiresAt: expiresAtUnix,
|
ExpiresAt: expiresAtUnix,
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload, expiresAt, ttl
|
return payload, expiresAt, ttl, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormDimension reads an optional width or height form field. An empty
|
||||||
|
// value means "original size" (0). A non-numeric value, or one
|
||||||
|
// imgcache.ValidateDimension rejects, is 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 {
|
||||||
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = imgcache.ValidateDimension(field, value)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseFormInt reads an optional integer form field, returning def when the
|
||||||
|
// field is empty and an error naming the field when the value is non-numeric
|
||||||
|
// or outside minValue to maxValue.
|
||||||
|
func parseFormInt(
|
||||||
|
form url.Values, field string, def, minValue, maxValue int,
|
||||||
|
) (int, error) {
|
||||||
|
raw := form.Get(field)
|
||||||
|
if raw == "" {
|
||||||
|
return def, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
value, err := strconv.Atoi(raw)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("%w %s: not a number", errInvalidFormField, field)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value < minValue || value > maxValue {
|
||||||
|
return 0, fmt.Errorf("%w %s: must be from %d to %d",
|
||||||
|
errInvalidFormField, field, minValue, maxValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
return value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatorData holds template data for the generator page.
|
// generatorData holds template data for the generator page.
|
||||||
@@ -215,6 +309,15 @@ func (s *Handlers) renderLogin(
|
|||||||
|
|
||||||
func (s *Handlers) renderGenerator(
|
func (s *Handlers) renderGenerator(
|
||||||
w http.ResponseWriter, r *http.Request, data *generatorData,
|
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; a rejected form uses 400. The page is rendered into a buffer before
|
||||||
|
// the status is written, so a template failure can still answer 500.
|
||||||
|
func (s *Handlers) renderGeneratorStatus(
|
||||||
|
w http.ResponseWriter, r *http.Request, data *generatorData, status int,
|
||||||
) {
|
) {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
@@ -224,17 +327,29 @@ func (s *Handlers) renderGenerator(
|
|||||||
|
|
||||||
data.CSRFField = csrfField(r)
|
data.CSRFField = csrfField(r)
|
||||||
|
|
||||||
err := templates.Render(w, "generator.html", data)
|
var page bytes.Buffer
|
||||||
|
|
||||||
|
err := templates.Render(&page, "generator.html", data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to render generator template", "error", err)
|
s.log.Error("failed to render generator template", "error", err)
|
||||||
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
http.Error(w, "Internal server error", http.StatusInternalServerError)
|
||||||
|
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(status)
|
||||||
|
|
||||||
|
_, err = page.WriteTo(w)
|
||||||
|
if err != nil {
|
||||||
|
s.log.Error("failed to write generator page", "error", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
|
w http.ResponseWriter, r *http.Request, errorMsg string,
|
||||||
|
form url.Values, status int,
|
||||||
) {
|
) {
|
||||||
s.renderGenerator(w, r, &generatorData{
|
s.renderGeneratorStatus(w, r, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
@@ -243,7 +358,7 @@ func (s *Handlers) renderGeneratorWithForm(
|
|||||||
FormQuality: form.Get("quality"),
|
FormQuality: form.Get("quality"),
|
||||||
FormFit: form.Get("fit"),
|
FormFit: form.Get("fit"),
|
||||||
FormTTL: form.Get("ttl"),
|
FormTTL: form.Get("ttl"),
|
||||||
})
|
}, status)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
func (s *Handlers) buildGeneratedURL(r *http.Request, token, format string) string {
|
||||||
|
|||||||
@@ -0,0 +1,163 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"maps"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/encurl"
|
||||||
|
"sneak.berlin/go/pixa/internal/imgcache"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Generator form field names, and a value that is not a number.
|
||||||
|
const (
|
||||||
|
widthField = "width"
|
||||||
|
heightField = "height"
|
||||||
|
qualityField = "quality"
|
||||||
|
ttlField = "ttl"
|
||||||
|
fitField = "fit"
|
||||||
|
notANumber = "abc"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGeneratePostRejectsBadField verifies that each generator field whose
|
||||||
|
// value is not a number, out of range, or unrecognized is rejected with 400,
|
||||||
|
// mints no token, and is named in the error shown on the page.
|
||||||
|
func TestGeneratePostRejectsBadField(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
field, value, wantError string
|
||||||
|
}{
|
||||||
|
{widthField, notANumber, "invalid width: not a number"},
|
||||||
|
{widthField, "-1", "width is negative"},
|
||||||
|
{widthField, "8193", "width is above 8192"},
|
||||||
|
{heightField, notANumber, "invalid height: not a number"},
|
||||||
|
{heightField, "8193", "height is above 8192"},
|
||||||
|
{qualityField, notANumber, "invalid quality: not a number"},
|
||||||
|
{qualityField, "0", "invalid quality: must be from 1 to 100"},
|
||||||
|
{qualityField, "101", "invalid quality: must be from 1 to 100"},
|
||||||
|
{ttlField, notANumber, "invalid ttl: not a number"},
|
||||||
|
{ttlField, "-1", "invalid ttl: must be from 0 to"},
|
||||||
|
{ttlField, "10000000000", "invalid ttl: must be from 0 to"},
|
||||||
|
{fitField, "bogus", "invalid fit mode: bogus"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.field+"="+tt.value, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
rec := generatePost(t, url.Values{tt.field: {tt.value}})
|
||||||
|
body := rec.Body.String()
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(body, "/v1/e/") {
|
||||||
|
t.Error("a token was generated")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !strings.Contains(body, tt.wantError) {
|
||||||
|
t.Errorf("page does not show %q", tt.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBuildGeneratePayloadDefaultAndLimits verifies that an empty quality
|
||||||
|
// takes the default, and that the largest accepted width, height, quality and
|
||||||
|
// ttl are accepted with an expiry still in the future.
|
||||||
|
func TestBuildGeneratePayloadDefaultAndLimits(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parsed, err := url.Parse(testSourceURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("url.Parse() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, _, _, err := buildGeneratePayload(parsed, url.Values{})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("empty form: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if payload.Quality != encurl.DefaultQuality {
|
||||||
|
t.Errorf("empty quality gave %d, want %d",
|
||||||
|
payload.Quality, encurl.DefaultQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, expiresAt, _, err := buildGeneratePayload(parsed, url.Values{
|
||||||
|
widthField: {strconv.Itoa(imgcache.MaxDimension)},
|
||||||
|
heightField: {strconv.Itoa(imgcache.MaxDimension)},
|
||||||
|
qualityField: {strconv.Itoa(maxQuality)},
|
||||||
|
ttlField: {strconv.Itoa(maxTTL)},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("largest accepted values: error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !expiresAt.After(time.Now()) {
|
||||||
|
t.Errorf("ttl %d gave expiry %v, want a time in the future",
|
||||||
|
maxTTL, expiresAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -276,3 +276,18 @@ func TestHandleImage_ETagHeader(t *testing.T) {
|
|||||||
t.Errorf("ETag should be quoted, got %q", etag)
|
t.Errorf("ETag should be quoted, got %q", etag)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandleImage_InvalidFitMode_Returns400 verifies that the plain image
|
||||||
|
// route rejects an unrecognized fit mode with 400.
|
||||||
|
func TestHandleImage_InvalidFitMode_Returns400(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
fix := setupTestHandler(t)
|
||||||
|
|
||||||
|
status := getImage(t, fix,
|
||||||
|
"/v1/image/"+fix.goodHost+"/images/photo.jpg/50x50.jpeg?fit=bogus")
|
||||||
|
|
||||||
|
if status != http.StatusBadRequest {
|
||||||
|
t.Fatalf("status = %d, want %d", status, http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -110,13 +110,6 @@ func (s *Handlers) parseImageRequest(
|
|||||||
|
|
||||||
if fit := query.Get("fit"); fit != "" {
|
if fit := query.Get("fit"); fit != "" {
|
||||||
req.FitMode = imgcache.FitMode(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
|
// Default quality if not set
|
||||||
@@ -129,6 +122,18 @@ func (s *Handlers) parseImageRequest(
|
|||||||
req.FitMode = imgcache.FitCover
|
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
|
return req, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,19 @@ func (s *Handlers) HandleImageEnc() http.HandlerFunc {
|
|||||||
// Convert payload to ImageRequest
|
// Convert payload to ImageRequest
|
||||||
req := payload.ToImageRequest()
|
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
|
// Log the request
|
||||||
s.log.Debug("encrypted image request",
|
s.log.Debug("encrypted image request",
|
||||||
"host", req.SourceHost,
|
"host", req.SourceHost,
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,24 @@ func ValidateFitMode(fit FitMode) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ValidateImageRequest checks a request's width and height with
|
||||||
|
// ValidateDimension and its fit mode with ValidateFitMode. 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.
|
||||||
|
func ValidateImageRequest(req *ImageRequest) error {
|
||||||
|
err := ValidateDimension("width", req.Size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", req.Size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return ValidateFitMode(req.FitMode)
|
||||||
|
}
|
||||||
|
|
||||||
// ImageRequest represents a request for a processed image
|
// ImageRequest represents a request for a processed image
|
||||||
type ImageRequest struct {
|
type ImageRequest struct {
|
||||||
// SourceHost is the origin host (e.g., "cdn.example.com")
|
// SourceHost is the origin host (e.g., "cdn.example.com")
|
||||||
|
|||||||
@@ -23,6 +23,21 @@ var (
|
|||||||
// MaxDimension is the maximum allowed width or height.
|
// MaxDimension is the maximum allowed width or height.
|
||||||
const MaxDimension = 8192
|
const MaxDimension = 8192
|
||||||
|
|
||||||
|
// ValidateDimension checks one requested width or height; name ("width" or
|
||||||
|
// "height") appears in the error. 0 means "original size" and is valid.
|
||||||
|
func ValidateDimension(name string, value int) error {
|
||||||
|
if value < 0 {
|
||||||
|
return fmt.Errorf("%w: %s is negative", ErrInvalidSize, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
if value > MaxDimension {
|
||||||
|
return fmt.Errorf("%w: %s is above %d",
|
||||||
|
ErrDimensionTooLarge, name, MaxDimension)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
// sizeFormatRegex matches patterns like "800x600.webp", "0x0.jpeg", "orig.png"
|
||||||
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
var sizeFormatRegex = regexp.MustCompile(`^(\d+)x(\d+)\.(\w+)$|^(orig)\.(\w+)$`)
|
||||||
|
|
||||||
@@ -225,14 +240,20 @@ func parseSizeFormat(s string) (Size, ImageFormat, error) {
|
|||||||
return Size{}, "", ErrInvalidSize
|
return Size{}, "", ErrInvalidSize
|
||||||
}
|
}
|
||||||
|
|
||||||
if width > MaxDimension || height > MaxDimension {
|
|
||||||
return Size{}, "", ErrDimensionTooLarge
|
|
||||||
}
|
|
||||||
|
|
||||||
size = Size{Width: width, Height: height}
|
size = Size{Width: width, Height: height}
|
||||||
formatStr = matches[3]
|
formatStr = matches[3]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err := ValidateDimension("width", size.Width)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = ValidateDimension("height", size.Height)
|
||||||
|
if err != nil {
|
||||||
|
return Size{}, "", err
|
||||||
|
}
|
||||||
|
|
||||||
format, err := parseFormat(formatStr)
|
format, err := parseFormat(formatStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Size{}, "", err
|
return Size{}, "", err
|
||||||
|
|||||||
@@ -0,0 +1,64 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -73,7 +73,7 @@
|
|||||||
id="width"
|
id="width"
|
||||||
name="width"
|
name="width"
|
||||||
min="0"
|
min="0"
|
||||||
max="10000"
|
max="8192"
|
||||||
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
value="{{if .FormWidth}}{{.FormWidth}}{{else}}0{{end}}"
|
||||||
placeholder="0 = original"
|
placeholder="0 = original"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
@@ -88,7 +88,7 @@
|
|||||||
id="height"
|
id="height"
|
||||||
name="height"
|
name="height"
|
||||||
min="0"
|
min="0"
|
||||||
max="10000"
|
max="8192"
|
||||||
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
value="{{if .FormHeight}}{{.FormHeight}}{{else}}0{{end}}"
|
||||||
placeholder="0 = original"
|
placeholder="0 = original"
|
||||||
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
class="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||||
|
|||||||
Reference in New Issue
Block a user