Compare commits
2
Commits
next
..
460c11a7bf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
460c11a7bf | ||
|
|
7f6139e6d6 |
@@ -75,7 +75,7 @@ or partial matching is supported.
|
|||||||
**Signed data format** (colon-separated):
|
**Signed data format** (colon-separated):
|
||||||
|
|
||||||
```
|
```
|
||||||
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration")
|
HMAC-SHA256(secret, "host:path:query:width:height:format:expiration:quality:fit")
|
||||||
```
|
```
|
||||||
|
|
||||||
Where:
|
Where:
|
||||||
@@ -87,13 +87,20 @@ Where:
|
|||||||
- `height` — requested height in pixels, `0` for original
|
- `height` — requested height in pixels, `0` for original
|
||||||
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
- `format` — output format (jpeg, png, webp, avif, gif, orig)
|
||||||
- `expiration` — Unix timestamp when signature expires
|
- `expiration` — Unix timestamp when signature expires
|
||||||
|
- `quality` — output quality 1-100; sign `85` (the default) when the URL
|
||||||
|
omits the `q` parameter
|
||||||
|
- `fit` — fit mode (cover, contain, fill, inside, outside); sign `cover`
|
||||||
|
(the default) when the URL omits the `fit` parameter
|
||||||
|
|
||||||
**Example:** resize
|
The `q` and `fit` query parameters are covered by the signature. A URL
|
||||||
`https://cdn.example.com/photos/cat.jpg` to 800x600 WebP with
|
signed for one quality or fit value will not verify when replayed with a
|
||||||
expiration 1704067200:
|
different value; the effective (post-default) value is what is signed.
|
||||||
|
|
||||||
|
**Example:** resize `https://cdn.example.com/photos/cat.jpg` to 800x600
|
||||||
|
WebP with expiration 1704067200, default quality and fit:
|
||||||
|
|
||||||
1. Build input:
|
1. Build input:
|
||||||
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200`
|
`cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover`
|
||||||
2. Compute HMAC-SHA256 with your secret key
|
2. Compute HMAC-SHA256 with your secret key
|
||||||
3. Base64URL-encode the result
|
3. Base64URL-encode the result
|
||||||
4. URL:
|
4. URL:
|
||||||
|
|||||||
@@ -1,27 +1,21 @@
|
|||||||
# Workflow
|
# Workflow
|
||||||
|
|
||||||
* branch per issue from `next`
|
* branch (from `main`)
|
||||||
* do the work in Next Step
|
* do the work in Next Step
|
||||||
* move Next Step to the top of Completed Steps
|
* move Next Step to the top of Completed Steps
|
||||||
* move the top item of Future Steps into Next Step
|
* move the top item of Future Steps into Next Step
|
||||||
* commit (`TODO.md` changes in the same commit as the work)
|
* commit (`TODO.md` changes in the same commit as the work)
|
||||||
* open a PR based on `next`
|
* merge to `main` if the branch is not protected, otherwise open a PR
|
||||||
* 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
|
|
||||||
* push
|
* push
|
||||||
|
|
||||||
# Status
|
# Status
|
||||||
|
|
||||||
pre-1.0. No git tags exist. The `1.0.0` milestone is in progress; work
|
pre-1.0. No git tags exist. Recent work extracted the internal/magic,
|
||||||
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,
|
|
||||||
internal/allowlist, internal/httpfetcher, and internal/signature
|
internal/allowlist, internal/httpfetcher, and internal/signature
|
||||||
packages. The gosec findings from the 2026-07-06 survey are resolved.
|
packages. The gosec findings from the 2026-07-06 survey are resolved
|
||||||
The disk cache is now size-bounded with LRU eviction
|
and `make check` is green on main. The disk cache is now size-bounded
|
||||||
(`cache_max_bytes`), closing the unbounded disk growth DoS vector.
|
with LRU eviction (`cache_max_bytes`), closing the unbounded disk
|
||||||
|
growth DoS vector.
|
||||||
|
|
||||||
# Next Step
|
# Next Step
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ require (
|
|||||||
github.com/getsentry/sentry-go v0.40.0
|
github.com/getsentry/sentry-go v0.40.0
|
||||||
github.com/go-chi/chi/v5 v5.2.3
|
github.com/go-chi/chi/v5 v5.2.3
|
||||||
github.com/go-chi/cors v1.2.2
|
github.com/go-chi/cors v1.2.2
|
||||||
github.com/gorilla/csrf v1.7.3
|
|
||||||
github.com/gorilla/securecookie v1.1.2
|
github.com/gorilla/securecookie v1.1.2
|
||||||
github.com/prometheus/client_golang v1.23.2
|
github.com/prometheus/client_golang v1.23.2
|
||||||
github.com/slok/go-http-metrics v0.13.0
|
github.com/slok/go-http-metrics v0.13.0
|
||||||
|
|||||||
@@ -175,8 +175,6 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU
|
|||||||
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
github.com/googleapis/gax-go/v2 v2.14.2 h1:eBLnkZ9635krYIPD+ag1USrOAI0Nr0QYF3+/3GqO0k0=
|
||||||
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
github.com/googleapis/gax-go/v2 v2.14.2/go.mod h1:ON64QhlJkhVtSqp4v1uaK92VyZ2gmvDQsweuyLV+8+w=
|
||||||
github.com/gorilla/csrf v1.7.3 h1:BHWt6FTLZAb2HtWT5KDBf6qgpZzvtbp9QWDRKZMXJC0=
|
|
||||||
github.com/gorilla/csrf v1.7.3/go.mod h1:F1Fj3KG23WYHE6gozCmBAezKookxbIvUJT+121wTuLk=
|
|
||||||
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
github.com/gorilla/securecookie v1.1.2 h1:YCIWL56dvtr73r6715mJs5ZvhtnY73hBvEF8kXD8ePA=
|
||||||
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
github.com/gorilla/securecookie v1.1.2/go.mod h1:NfCASbcHqRSY+3a8tlWJwsQap2VX5pwzwo4h3eOamfo=
|
||||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
|
||||||
|
|||||||
+15
-25
@@ -2,7 +2,6 @@ package handlers
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
"html/template"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
@@ -24,13 +23,13 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
|
|
||||||
// Check if authenticated
|
// Check if authenticated
|
||||||
if s.sessMgr.IsAuthenticated(r) {
|
if s.sessMgr.IsAuthenticated(r) {
|
||||||
s.renderGenerator(w, r, nil)
|
s.renderGenerator(w, nil)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show login page
|
// Show login page
|
||||||
s.renderLogin(w, r, "")
|
s.renderLogin(w, "")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,7 +37,7 @@ func (s *Handlers) HandleRoot() http.HandlerFunc {
|
|||||||
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderLogin(w, r, "Invalid form data")
|
s.renderLogin(w, "Invalid form data")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -48,7 +47,7 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Constant-time comparison to prevent timing attacks
|
// Constant-time comparison to prevent timing attacks
|
||||||
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
if subtle.ConstantTimeCompare([]byte(submittedKey), []byte(s.config.SigningKey)) != 1 {
|
||||||
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
s.log.Warn("failed login attempt", "remote_addr", r.RemoteAddr)
|
||||||
s.renderLogin(w, r, "Invalid signing key")
|
s.renderLogin(w, "Invalid signing key")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -57,7 +56,7 @@ func (s *Handlers) handleLoginPost(w http.ResponseWriter, r *http.Request) {
|
|||||||
err = s.sessMgr.CreateSession(w)
|
err = s.sessMgr.CreateSession(w)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.log.Error("failed to create session", "error", err)
|
s.log.Error("failed to create session", "error", err)
|
||||||
s.renderLogin(w, r, "Failed to create session")
|
s.renderLogin(w, "Failed to create session")
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -88,7 +87,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
|
|
||||||
err := r.ParseForm()
|
err := r.ParseForm()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
s.renderGenerator(w, r, &generatorData{Error: "Invalid form data"})
|
s.renderGenerator(w, &generatorData{Error: "Invalid form data"})
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -98,7 +97,7 @@ 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, "Invalid source URL", r.Form)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -109,7 +108,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
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, "Failed to generate URL", r.Form)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -122,7 +121,7 @@ func (s *Handlers) HandleGenerateURL() http.HandlerFunc {
|
|||||||
expiresAtStr = expiresAt.Format(time.RFC3339)
|
expiresAtStr = expiresAt.Format(time.RFC3339)
|
||||||
}
|
}
|
||||||
|
|
||||||
s.renderGenerator(w, r, &generatorData{
|
s.renderGenerator(w, &generatorData{
|
||||||
GeneratedURL: generatedURL,
|
GeneratedURL: generatedURL,
|
||||||
ExpiresAt: expiresAtStr,
|
ExpiresAt: expiresAtStr,
|
||||||
FormURL: sourceURL,
|
FormURL: sourceURL,
|
||||||
@@ -187,20 +186,15 @@ type generatorData struct {
|
|||||||
FormQuality string
|
FormQuality string
|
||||||
FormFit string
|
FormFit string
|
||||||
FormTTL string
|
FormTTL string
|
||||||
CSRFField template.HTML
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderLogin(
|
func (s *Handlers) renderLogin(w http.ResponseWriter, errorMsg string) {
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string,
|
|
||||||
) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
data := struct {
|
data := struct {
|
||||||
Error string
|
Error string
|
||||||
CSRFField template.HTML
|
|
||||||
}{
|
}{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
CSRFField: csrfField(r),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err := templates.Render(w, "login.html", data)
|
err := templates.Render(w, "login.html", data)
|
||||||
@@ -210,17 +204,13 @@ func (s *Handlers) renderLogin(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGenerator(
|
func (s *Handlers) renderGenerator(w http.ResponseWriter, data *generatorData) {
|
||||||
w http.ResponseWriter, r *http.Request, data *generatorData,
|
|
||||||
) {
|
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
|
|
||||||
if data == nil {
|
if data == nil {
|
||||||
data = &generatorData{}
|
data = &generatorData{}
|
||||||
}
|
}
|
||||||
|
|
||||||
data.CSRFField = csrfField(r)
|
|
||||||
|
|
||||||
err := templates.Render(w, "generator.html", data)
|
err := templates.Render(w, "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)
|
||||||
@@ -229,9 +219,9 @@ func (s *Handlers) renderGenerator(
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Handlers) renderGeneratorWithForm(
|
func (s *Handlers) renderGeneratorWithForm(
|
||||||
w http.ResponseWriter, r *http.Request, errorMsg string, form url.Values,
|
w http.ResponseWriter, errorMsg string, form url.Values,
|
||||||
) {
|
) {
|
||||||
s.renderGenerator(w, r, &generatorData{
|
s.renderGenerator(w, &generatorData{
|
||||||
Error: errorMsg,
|
Error: errorMsg,
|
||||||
FormURL: form.Get("url"),
|
FormURL: form.Get("url"),
|
||||||
FormWidth: form.Get("width"),
|
FormWidth: form.Get("width"),
|
||||||
|
|||||||
@@ -1,273 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"log/slog"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"net/url"
|
|
||||||
"regexp"
|
|
||||||
"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"
|
|
||||||
)
|
|
||||||
|
|
||||||
// testSigningKey is a throwaway signing key for the CSRF flow tests. It
|
|
||||||
// seeds the session manager, the encrypted-URL generator, and the CSRF
|
|
||||||
// token key, exactly as the real signing key does in production.
|
|
||||||
const testSigningKey = "test-signing-key-0123456789abcdef"
|
|
||||||
|
|
||||||
// Form field names used in the CSRF flow tests.
|
|
||||||
const (
|
|
||||||
loginKeyField = "key"
|
|
||||||
// gorilla/csrf's default form field name, not a credential.
|
|
||||||
csrfTokenField = "gorilla.csrf.Token" //nolint:gosec // G101 false positive
|
|
||||||
)
|
|
||||||
|
|
||||||
// csrfFieldPattern extracts the token rendered by csrf.TemplateField into
|
|
||||||
// the form. The field name is gorilla/csrf's default.
|
|
||||||
var csrfFieldPattern = regexp.MustCompile(
|
|
||||||
`name="gorilla\.csrf\.Token" value="([^"]+)"`)
|
|
||||||
|
|
||||||
// newCSRFTestRouter builds a router that mirrors the production wiring for
|
|
||||||
// the CSRF-protected UI routes (see server.SetupRoutes): the login and
|
|
||||||
// generator forms and their POST targets sit behind the real CSRF
|
|
||||||
// middleware. Requests are marked plaintext (Debug: true) so the flow runs
|
|
||||||
// over httptest's http transport without an https Referer.
|
|
||||||
func newCSRFTestRouter(t *testing.T) (*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.CSRF())
|
|
||||||
r.Get("/", h.HandleRoot())
|
|
||||||
r.Post("/", h.HandleRoot())
|
|
||||||
r.Post("/generate", h.HandleGenerateURL())
|
|
||||||
})
|
|
||||||
|
|
||||||
return h, r
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfCredentials performs a GET that renders a form and returns the CSRF
|
|
||||||
// cookies the middleware set and the token embedded in the form. Passing
|
|
||||||
// the authenticated session cookie renders the generator form instead of
|
|
||||||
// the login form.
|
|
||||||
func csrfCredentials(
|
|
||||||
t *testing.T, srv http.Handler, reqCookies []*http.Cookie,
|
|
||||||
) ([]*http.Cookie, string) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
req := httptest.NewRequestWithContext(
|
|
||||||
context.Background(), http.MethodGet, "/", nil)
|
|
||||||
for _, c := range reqCookies {
|
|
||||||
req.AddCookie(c)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("GET / status = %d, want %d", rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
match := csrfFieldPattern.FindStringSubmatch(rec.Body.String())
|
|
||||||
if match == nil {
|
|
||||||
t.Fatalf("no CSRF token field found in rendered form")
|
|
||||||
}
|
|
||||||
|
|
||||||
return rec.Result().Cookies(), match[1]
|
|
||||||
}
|
|
||||||
|
|
||||||
// postForm submits form values with the given cookies and returns the
|
|
||||||
// recorder.
|
|
||||||
func postForm(
|
|
||||||
srv http.Handler, path string,
|
|
||||||
cookies []*http.Cookie, form url.Values,
|
|
||||||
) *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)
|
|
||||||
}
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
srv.ServeHTTP(rec, req)
|
|
||||||
|
|
||||||
return rec
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPostRejectedWithoutToken verifies that POST / with no CSRF token
|
|
||||||
// is rejected. This is login CSRF: no session cookie exists yet, so the
|
|
||||||
// protection must rest on a token bound to a pre-session cookie.
|
|
||||||
func TestLoginPostRejectedWithoutToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/", nil, url.Values{loginKeyField: {testSigningKey}})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("POST / without token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPostRejectedWithForeignToken verifies that a token that does not
|
|
||||||
// match the request's CSRF cookie is rejected: a token minted for one
|
|
||||||
// cookie cannot authorize a request carrying a different cookie.
|
|
||||||
func TestLoginPostRejectedWithForeignToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
cookiesA, _ := csrfCredentials(t, srv, nil)
|
|
||||||
_, tokenB := csrfCredentials(t, srv, nil)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/", cookiesA, url.Values{
|
|
||||||
loginKeyField: {testSigningKey},
|
|
||||||
csrfTokenField: {tokenB},
|
|
||||||
})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("POST / with foreign token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestLoginPostAcceptedWithValidToken verifies that POST / with a matching
|
|
||||||
// cookie and token succeeds: the login is processed and a session is
|
|
||||||
// established (303 redirect).
|
|
||||||
func TestLoginPostAcceptedWithValidToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
_, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
cookies, token := csrfCredentials(t, srv, nil)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/", cookies, url.Values{
|
|
||||||
loginKeyField: {testSigningKey},
|
|
||||||
csrfTokenField: {token},
|
|
||||||
})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusSeeOther {
|
|
||||||
t.Fatalf("POST / with valid token 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("valid login did not set a session cookie")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostRejectedWithoutToken verifies that POST /generate is
|
|
||||||
// rejected without a CSRF token even when the request carries a valid
|
|
||||||
// authenticated session. The session cookie is not sufficient; the policy
|
|
||||||
// requires a CSRF token on this cookie-authenticated form.
|
|
||||||
func TestGeneratePostRejectedWithoutToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
sessionCookie := newSessionCookie(t, h)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/generate",
|
|
||||||
[]*http.Cookie{sessionCookie},
|
|
||||||
url.Values{"url": {"https://example.com/a.jpg"}})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusForbidden {
|
|
||||||
t.Errorf("POST /generate without token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusForbidden)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestGeneratePostAcceptedWithValidToken verifies that POST /generate
|
|
||||||
// succeeds with a valid session and a matching CSRF cookie and token.
|
|
||||||
func TestGeneratePostAcceptedWithValidToken(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
h, srv := newCSRFTestRouter(t)
|
|
||||||
|
|
||||||
sessionCookie := newSessionCookie(t, h)
|
|
||||||
|
|
||||||
cookies, token := csrfCredentials(t, srv, []*http.Cookie{sessionCookie})
|
|
||||||
cookies = append(cookies, sessionCookie)
|
|
||||||
|
|
||||||
rec := postForm(srv, "/generate", cookies, url.Values{
|
|
||||||
"url": {"https://example.com/a.jpg"},
|
|
||||||
"format": {"jpeg"},
|
|
||||||
csrfTokenField: {token},
|
|
||||||
})
|
|
||||||
|
|
||||||
if rec.Code != http.StatusOK {
|
|
||||||
t.Fatalf("POST /generate with valid token status = %d, want %d",
|
|
||||||
rec.Code, http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !strings.Contains(rec.Body.String(), "/v1/e/") {
|
|
||||||
t.Error("generator response did not contain a generated URL")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// newSessionCookie creates an authenticated session cookie via the
|
|
||||||
// handler's session manager.
|
|
||||||
func newSessionCookie(t *testing.T, h *Handlers) *http.Cookie {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
rec := httptest.NewRecorder()
|
|
||||||
|
|
||||||
err := h.sessMgr.CreateSession(rec)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("CreateSession() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, c := range rec.Result().Cookies() {
|
|
||||||
if c.Name == session.CookieName {
|
|
||||||
return c
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
t.Fatalf("session manager did not set a %q cookie", session.CookieName)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -1,65 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"html/template"
|
|
||||||
"net/http"
|
|
||||||
|
|
||||||
"github.com/gorilla/csrf"
|
|
||||||
|
|
||||||
"sneak.berlin/go/pixa/internal/seal"
|
|
||||||
)
|
|
||||||
|
|
||||||
// csrfKeySalt provides domain separation for the CSRF authentication key,
|
|
||||||
// derived from the signing key so tokens survive restarts without extra
|
|
||||||
// configuration and never reuse the session or encrypted-URL key material.
|
|
||||||
const csrfKeySalt = "pixa-csrf-v1"
|
|
||||||
|
|
||||||
// newCSRFProtect builds the CSRF-protection middleware for the
|
|
||||||
// state-mutating HTML form routes. The token lives in its own cookie,
|
|
||||||
// independent of the session cookie, so it also protects the login POST
|
|
||||||
// where no session exists yet (login CSRF).
|
|
||||||
//
|
|
||||||
// When plaintext is true (local HTTP development), requests are marked
|
|
||||||
// plaintext so the library neither demands an https Referer nor sets a
|
|
||||||
// Secure cookie the browser would withhold over http. In production the
|
|
||||||
// service runs behind a TLS-terminating proxy, so plaintext is false and
|
|
||||||
// the library enforces its https Referer origin check.
|
|
||||||
func newCSRFProtect(
|
|
||||||
signingKey string, plaintext bool,
|
|
||||||
) (func(http.Handler) http.Handler, error) {
|
|
||||||
key, err := seal.DeriveKey([]byte(signingKey), csrfKeySalt)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
protect := csrf.Protect(
|
|
||||||
key[:],
|
|
||||||
csrf.Path("/"),
|
|
||||||
csrf.Secure(!plaintext),
|
|
||||||
csrf.SameSite(csrf.SameSiteStrictMode),
|
|
||||||
)
|
|
||||||
|
|
||||||
if !plaintext {
|
|
||||||
return protect, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return func(next http.Handler) http.Handler {
|
|
||||||
protected := protect(next)
|
|
||||||
|
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
protected.ServeHTTP(w, csrf.PlaintextHTTPRequest(r))
|
|
||||||
})
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// CSRF returns the CSRF-protection middleware for the login and generator
|
|
||||||
// form routes.
|
|
||||||
func (s *Handlers) CSRF() func(http.Handler) http.Handler {
|
|
||||||
return s.csrfProtect
|
|
||||||
}
|
|
||||||
|
|
||||||
// csrfField returns the hidden form input carrying the CSRF token for the
|
|
||||||
// given request, to be embedded verbatim in a rendered form.
|
|
||||||
func csrfField(r *http.Request) template.HTML {
|
|
||||||
return csrf.TemplateField(r)
|
|
||||||
}
|
|
||||||
@@ -31,30 +31,23 @@ type Params struct {
|
|||||||
|
|
||||||
// Handlers provides HTTP request handlers.
|
// Handlers provides HTTP request handlers.
|
||||||
type Handlers struct {
|
type Handlers struct {
|
||||||
log *slog.Logger
|
log *slog.Logger
|
||||||
hc *healthcheck.Healthcheck
|
hc *healthcheck.Healthcheck
|
||||||
db *database.Database
|
db *database.Database
|
||||||
config *config.Config
|
config *config.Config
|
||||||
imgSvc *imgcache.Service
|
imgSvc *imgcache.Service
|
||||||
imgCache *imgcache.Cache
|
imgCache *imgcache.Cache
|
||||||
sessMgr *session.Manager
|
sessMgr *session.Manager
|
||||||
encGen *encurl.Generator
|
encGen *encurl.Generator
|
||||||
csrfProtect func(http.Handler) http.Handler
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a new Handlers instance.
|
// New creates a new Handlers instance.
|
||||||
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
func New(lc fx.Lifecycle, params Params) (*Handlers, error) {
|
||||||
csrfProtect, err := newCSRFProtect(params.Config.SigningKey, params.Config.Debug)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
s := &Handlers{
|
s := &Handlers{
|
||||||
log: params.Logger.Get(),
|
log: params.Logger.Get(),
|
||||||
hc: params.Healthcheck,
|
hc: params.Healthcheck,
|
||||||
db: params.Database,
|
db: params.Database,
|
||||||
config: params.Config,
|
config: params.Config,
|
||||||
csrfProtect: csrfProtect,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lc.Append(fx.Hook{
|
lc.Append(fx.Hook{
|
||||||
|
|||||||
@@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -452,6 +452,8 @@ func signatureRequest(req *ImageRequest) *signature.Request {
|
|||||||
Width: req.Size.Width,
|
Width: req.Size.Width,
|
||||||
Height: req.Size.Height,
|
Height: req.Size.Height,
|
||||||
Format: string(req.Format),
|
Format: string(req.Format),
|
||||||
|
Quality: req.Quality,
|
||||||
|
FitMode: string(req.FitMode),
|
||||||
Signature: req.Signature,
|
Signature: req.Signature,
|
||||||
Expires: req.Expires,
|
Expires: req.Expires,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,33 +21,6 @@ import (
|
|||||||
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
|
// CORSMaxAgeSeconds is the max age for CORS preflight cache (24 hours).
|
||||||
const CORSMaxAgeSeconds = 86400
|
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.
|
// Params defines dependencies for Middleware.
|
||||||
type Params struct {
|
type Params struct {
|
||||||
fx.In
|
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)
|
// Disable XSS filtering (modern browsers don't need it, can cause issues)
|
||||||
w.Header().Set("X-XSS-Protection", "0")
|
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)
|
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) {
|
func TestSecurityHeaders_PreservesExistingHeaders(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -44,17 +44,11 @@ func (s *Server) SetupRoutes() {
|
|||||||
// Static files (Tailwind CSS, etc.)
|
// Static files (Tailwind CSS, etc.)
|
||||||
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
s.router.Handle("/static/*", http.StripPrefix("/static/", static.Handler()))
|
||||||
|
|
||||||
// Login/generator UI. The form routes carry CSRF protection; the
|
// Login/generator UI
|
||||||
// token cookie is independent of the session cookie, so it also
|
s.router.Get("/", s.h.HandleRoot())
|
||||||
// covers the login POST, where no session exists yet.
|
s.router.Post("/", s.h.HandleRoot())
|
||||||
s.router.Group(func(r chi.Router) {
|
|
||||||
r.Use(s.h.CSRF())
|
|
||||||
r.Get("/", s.h.HandleRoot())
|
|
||||||
r.Post("/", s.h.HandleRoot())
|
|
||||||
r.Post("/generate", s.h.HandleGenerateURL())
|
|
||||||
})
|
|
||||||
|
|
||||||
s.router.Get("/logout", s.h.HandleLogout())
|
s.router.Get("/logout", s.h.HandleLogout())
|
||||||
|
s.router.Post("/generate", s.h.HandleGenerateURL())
|
||||||
|
|
||||||
// Main image proxy route
|
// Main image proxy route
|
||||||
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
// /v1/image/<host>/<path>/<width>x<height>.<format>
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
package signature_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Fixed inputs for the quality/fit golden vectors. They are independent of
|
||||||
|
// the constants in golden_test.go so this file pins the current signed
|
||||||
|
// format on its own.
|
||||||
|
const (
|
||||||
|
qfSigningKey = "golden-test-key"
|
||||||
|
qfExpiresUnix int64 = 1704067200 // 2024-01-01T00:00:00Z
|
||||||
|
qfFitCover = "cover"
|
||||||
|
qfFitContain = "contain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type qualityFitGoldenVector struct {
|
||||||
|
name string
|
||||||
|
req signature.Request
|
||||||
|
// wantSignature is the exact base64url (RFC 4648 URL-safe, padded)
|
||||||
|
// HMAC-SHA256 signature for the request with Expires set to
|
||||||
|
// qfExpiresUnix, under the signed format
|
||||||
|
// "host:path:query:width:height:format:expiration:quality:fit".
|
||||||
|
wantSignature string
|
||||||
|
}
|
||||||
|
|
||||||
|
// qualityFitGoldenVectors returns the known-answer vectors that pin quality
|
||||||
|
// and fit as signed components. The three default-value vectors use the
|
||||||
|
// effective quality (85) and fit ("cover") the handler applies when a URL
|
||||||
|
// omits q and fit, so they are the signatures real signed URLs must carry.
|
||||||
|
func qualityFitGoldenVectors() []qualityFitGoldenVector {
|
||||||
|
return []qualityFitGoldenVector{
|
||||||
|
{
|
||||||
|
name: "resized, default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: qfFitCover,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:85:cover"
|
||||||
|
wantSignature: "kdqeGoW2SX7qnaYtoB970wEnLydn0UnIgQYQLfAnjXQ=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "resized with query, default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
SourceQuery: "token=abc&v=2",
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: qfFitCover,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg:token=abc&v=2:800:600:webp:1704067200:85:cover"
|
||||||
|
wantSignature: "pKgVBOTd_Q_EikI7MNQLC9Q8Hurdxzyv3EIYvVhqc2I=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "original size, default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 0,
|
||||||
|
Height: 0,
|
||||||
|
Format: testFormatPNG,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: qfFitCover,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg::0:0:png:1704067200:85:cover"
|
||||||
|
wantSignature: "6_rZ0yyVbGZRs8kG7n7HLgLi5Jt8vjiWQljIEL1jbIs=",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-default quality and fit",
|
||||||
|
req: signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 40,
|
||||||
|
FitMode: qfFitContain,
|
||||||
|
},
|
||||||
|
// "cdn.example.com:/photos/cat.jpg::800:600:webp:1704067200:40:contain"
|
||||||
|
wantSignature: "pGaXpPUbI3A7nMx-4T9bfq9bYWBNL0kY4bxlcv3g1F8=",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_GoldenVectors_QualityFit pins the exact HMAC-SHA256 signature
|
||||||
|
// output for requests that carry quality and fit as signed components. If
|
||||||
|
// these assertions fail, the signed byte format
|
||||||
|
// ("host:path:query:width:height:format:expiration:quality:fit") or the
|
||||||
|
// base64url encoding has changed, breaking every signature already issued.
|
||||||
|
// Update these constants only as part of a deliberate, documented signature
|
||||||
|
// format migration.
|
||||||
|
func TestSigner_GoldenVectors_QualityFit(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New(qfSigningKey)
|
||||||
|
|
||||||
|
for _, tt := range qualityFitGoldenVectors() {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signReq := tt.req
|
||||||
|
signReq.Expires = time.Unix(qfExpiresUnix, 0)
|
||||||
|
|
||||||
|
gotSignature := signer.Sign(&signReq)
|
||||||
|
if gotSignature != tt.wantSignature {
|
||||||
|
t.Errorf("Sign() = %q, want %q (signed byte format changed?)",
|
||||||
|
gotSignature, tt.wantSignature)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package signature_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"sneak.berlin/go/pixa/internal/signature"
|
||||||
|
)
|
||||||
|
|
||||||
|
// signedQualityFitRequest returns a request signed for quality 85 and fit
|
||||||
|
// mode "cover", the effective defaults the handler applies before
|
||||||
|
// verification.
|
||||||
|
func signedQualityFitRequest(signer *signature.Signer) *signature.Request {
|
||||||
|
req := &signature.Request{
|
||||||
|
SourceHost: testHost,
|
||||||
|
SourcePath: testPath,
|
||||||
|
Width: 800,
|
||||||
|
Height: 600,
|
||||||
|
Format: testFormatWebP,
|
||||||
|
Quality: 85,
|
||||||
|
FitMode: "cover",
|
||||||
|
Expires: time.Now().Add(1 * time.Hour),
|
||||||
|
}
|
||||||
|
req.Signature = signer.Sign(req)
|
||||||
|
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSigner_Verify_QualityAndFitAreSigned proves that quality and fit are
|
||||||
|
// covered by the signature: a URL signed for one quality or fit mode must
|
||||||
|
// not verify when replayed with a different quality or fit mode. This is the
|
||||||
|
// amplification vector from the issue — one signed URL replayed across many
|
||||||
|
// quality and fit values yields many unauthorized cache entries and
|
||||||
|
// transcodes — so it must be rejected.
|
||||||
|
func TestSigner_Verify_QualityAndFitAreSigned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
signer := signature.New("test-secret-key")
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
tamper func(r *signature.Request)
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "replayed with different quality",
|
||||||
|
tamper: func(r *signature.Request) { r.Quality = 40 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replayed with different fit mode",
|
||||||
|
tamper: func(r *signature.Request) { r.FitMode = "contain" },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range cases {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
req := signedQualityFitRequest(signer)
|
||||||
|
tt.tamper(req)
|
||||||
|
|
||||||
|
err := signer.Verify(req)
|
||||||
|
if !errors.Is(err, signature.ErrInvalid) {
|
||||||
|
t.Errorf("Verify() = %v, want %v", err, signature.ErrInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,15 @@ type Request struct {
|
|||||||
Height int
|
Height int
|
||||||
// Format is the requested output format (e.g. "webp").
|
// Format is the requested output format (e.g. "webp").
|
||||||
Format string
|
Format string
|
||||||
|
// Quality is the requested output quality (1-100) for lossy formats.
|
||||||
|
// It is the effective value the request resolves to: callers pass the
|
||||||
|
// default quality when the request omits the parameter, so an omitted
|
||||||
|
// quality signs identically to that same value stated explicitly.
|
||||||
|
Quality int
|
||||||
|
// FitMode is how the image is fit into the requested dimensions
|
||||||
|
// (e.g. "cover"). Like Quality it is the effective value: callers pass
|
||||||
|
// the default fit mode when the request omits the parameter.
|
||||||
|
FitMode string
|
||||||
// Signature is the HMAC signature to verify.
|
// Signature is the HMAC signature to verify.
|
||||||
Signature string
|
Signature string
|
||||||
// Expires is the signature expiration timestamp.
|
// Expires is the signature expiration timestamp.
|
||||||
@@ -56,7 +65,8 @@ func New(secretKey string) *Signer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Sign generates an HMAC-SHA256 signature for the given request.
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
||||||
// The signature covers: host + path + query + width + height + format + expiration.
|
// The signature covers: host + path + query + width + height + format +
|
||||||
|
// expiration + quality + fit.
|
||||||
func (s *Signer) Sign(req *Request) string {
|
func (s *Signer) Sign(req *Request) string {
|
||||||
data := s.buildSignatureData(req)
|
data := s.buildSignatureData(req)
|
||||||
mac := hmac.New(sha256.New, s.secretKey)
|
mac := hmac.New(sha256.New, s.secretKey)
|
||||||
@@ -68,7 +78,8 @@ func (s *Signer) Sign(req *Request) string {
|
|||||||
|
|
||||||
// Verify checks if the signature on the request is valid and not expired.
|
// Verify checks if the signature on the request is valid and not expired.
|
||||||
// Signatures are exact-match only: every component of the signed data
|
// Signatures are exact-match only: every component of the signed data
|
||||||
// (host, path, query, dimensions, format, expiration) must match exactly.
|
// (host, path, query, dimensions, format, expiration, quality, fit) must
|
||||||
|
// match exactly.
|
||||||
// No suffix matching, wildcard matching, or partial matching is supported.
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
||||||
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
||||||
// "other.cdn.example.com", and vice versa.
|
// "other.cdn.example.com", and vice versa.
|
||||||
@@ -142,11 +153,13 @@ func (s *Signer) GenerateSignedURL(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildSignatureData creates the string to be signed.
|
// buildSignatureData creates the string to be signed.
|
||||||
// Format: "host:path:query:width:height:format:expiration"
|
// Format: "host:path:query:width:height:format:expiration:quality:fit"
|
||||||
// All components are used verbatim (exact match). No normalization,
|
// All components are used verbatim (exact match). No normalization,
|
||||||
// suffix matching, or wildcard expansion is performed.
|
// suffix matching, or wildcard expansion is performed. Quality and fit
|
||||||
|
// are the effective transform values, so replaying a signed URL with a
|
||||||
|
// different quality or fit mode fails verification.
|
||||||
func (s *Signer) buildSignatureData(req *Request) string {
|
func (s *Signer) buildSignatureData(req *Request) string {
|
||||||
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d",
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
|
||||||
req.SourceHost,
|
req.SourceHost,
|
||||||
req.SourcePath,
|
req.SourcePath,
|
||||||
req.SourceQuery,
|
req.SourceQuery,
|
||||||
@@ -154,6 +167,8 @@ func (s *Signer) buildSignatureData(req *Request) string {
|
|||||||
req.Height,
|
req.Height,
|
||||||
req.Format,
|
req.Format,
|
||||||
req.Expires.Unix(),
|
req.Expires.Unix(),
|
||||||
|
req.Quality,
|
||||||
|
req.FitMode,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
<form method="POST" action="/generate" class="bg-white rounded-lg shadow-md p-6 space-y-4">
|
||||||
{{ .CSRFField }}
|
|
||||||
<div>
|
<div>
|
||||||
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="url" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Source URL
|
Source URL
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
<form method="POST" action="/" class="space-y-4">
|
<form method="POST" action="/" class="space-y-4">
|
||||||
{{ .CSRFField }}
|
|
||||||
<div>
|
<div>
|
||||||
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
<label for="key" class="block text-sm font-medium text-gray-700 mb-1">
|
||||||
Signing Key
|
Signing Key
|
||||||
|
|||||||
+1
-5
@@ -17,11 +17,7 @@ run_with_cgo_deps() {
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
echo "Running tests..."
|
echo "Running tests..."
|
||||||
# Run without -v first for clean output on success; on failure rerun
|
run_with_cgo_deps "CGO_ENABLED=1 go test -timeout 30s -race -v ./..."
|
||||||
# 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; }"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
Reference in New Issue
Block a user