check / check (push) Canceled after 0s
The image request now holds only its source and its transform, exactly what the variant key covers. The /v1/image/ handler checks the allowlist and verifies the signature itself with Signer.Verify(req, sig, expires). Both image handlers keep the URL's expiry as a local value for Cache-Control. The image service holds no signer, allowlist or AllowHTTP, and ValidateRequest is gone. The fetcher takes the source and builds its URL with Source.URL from its own AllowHTTP, so no URL string is built and parsed again. chooseAutoFormat returns the format it chooses instead of writing into the request. The signed URL format and every route's answers are unchanged. Model: opus-5-5
212 lines
6.0 KiB
Go
212 lines
6.0 KiB
Go
package handlers
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"sneak.berlin/go/pixa/internal/allowlist"
|
|
"sneak.berlin/go/pixa/internal/signature"
|
|
)
|
|
|
|
// Addresses the port tests below put on the allowlist.
|
|
const (
|
|
allowlistedIPv4 = "203.0.113.5"
|
|
allowlistedIPv6 = "2001:db8::1"
|
|
)
|
|
|
|
// newSignatureChecker returns Handlers that check signatures with
|
|
// testSigningKey and have allowlistHosts on their allowlist, as New builds
|
|
// them from signing_key and allowlist_hosts.
|
|
func newSignatureChecker(allowlistHosts ...string) *Handlers {
|
|
return &Handlers{
|
|
signer: signature.New(testSigningKey),
|
|
allowlist: allowlist.New(allowlistHosts),
|
|
}
|
|
}
|
|
|
|
func TestValidateSignature_NonAllowlistedHost_NoSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker(allowlistedHost)
|
|
|
|
// Should fail validation - not allowlisted and no signature
|
|
err := h.validateSignature(photoRequest(signedHost), "", time.Time{})
|
|
if err == nil {
|
|
t.Error("validateSignature() expected error for non-allowlisted " +
|
|
"host without signature")
|
|
}
|
|
}
|
|
|
|
func TestValidateSignature_NonAllowlistedHost_ValidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker(allowlistedHost)
|
|
req := photoRequest(signedHost)
|
|
|
|
// Generate a valid signature
|
|
expires := time.Now().Add(time.Hour)
|
|
sig := signature.New(testSigningKey).Sign(req, expires)
|
|
|
|
// Should pass validation
|
|
err := h.validateSignature(req, sig, expires)
|
|
if err != nil {
|
|
t.Errorf("validateSignature() error = %v", err)
|
|
}
|
|
}
|
|
|
|
func TestValidateSignature_NonAllowlistedHost_ExpiredSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker(allowlistedHost)
|
|
req := photoRequest(signedHost)
|
|
|
|
// Generate an expired signature
|
|
expires := time.Now().Add(-time.Hour)
|
|
sig := signature.New(testSigningKey).Sign(req, expires)
|
|
|
|
// Should fail validation
|
|
err := h.validateSignature(req, sig, expires)
|
|
if err == nil {
|
|
t.Error("validateSignature() expected error for expired signature")
|
|
}
|
|
}
|
|
|
|
func TestValidateSignature_NonAllowlistedHost_InvalidSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker(allowlistedHost)
|
|
|
|
// Should fail validation
|
|
err := h.validateSignature(photoRequest(signedHost), "invalid-signature",
|
|
time.Now().Add(time.Hour))
|
|
if err == nil {
|
|
t.Error("validateSignature() expected error for invalid signature")
|
|
}
|
|
}
|
|
|
|
// TestValidateSignature_NoAllowlist checks that with no host on the
|
|
// allowlist, a request without a signature fails validation.
|
|
func TestValidateSignature_NoAllowlist(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker()
|
|
|
|
err := h.validateSignature(photoRequest(signedHost), "", time.Time{})
|
|
if err == nil {
|
|
t.Error("validateSignature() expected error when no host is allowlisted")
|
|
}
|
|
}
|
|
|
|
// TestValidateSignature_SignatureExactHostMatch verifies that
|
|
// validateSignature enforces exact host matching for signatures. A
|
|
// signature for one host must not verify for a different host, even
|
|
// if they share a domain suffix.
|
|
func TestValidateSignature_SignatureExactHostMatch(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker()
|
|
|
|
// Sign a request for signedHost
|
|
signedReq := photoRequest(signedHost)
|
|
expires := time.Now().Add(time.Hour)
|
|
sig := signature.New(testSigningKey).Sign(signedReq, expires)
|
|
|
|
// The original request should pass validation
|
|
t.Run("exact host passes", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := h.validateSignature(signedReq, sig, expires)
|
|
if err != nil {
|
|
t.Errorf("validateSignature() exact host failed: %v", err)
|
|
}
|
|
})
|
|
|
|
// Try to reuse the signature with different hosts
|
|
tests := []struct {
|
|
name string
|
|
host string
|
|
}{
|
|
{"parent domain", "example.com"},
|
|
{"sibling subdomain", "images.example.com"},
|
|
{"deeper subdomain", "a." + signedHost},
|
|
{"evil suffix domain", signedHost + ".evil.com"},
|
|
{"prefixed host", "evil" + signedHost},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name+" rejected", func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := h.validateSignature(photoRequest(tt.host), sig, expires)
|
|
if err == nil {
|
|
t.Errorf("validateSignature() should reject signature for "+
|
|
"host %q (signed for %q)", tt.host, signedHost)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateSignature_HostWithPortNeedsSignature verifies that a host
|
|
// followed by a port, or by a colon with no port, matches no allowlist entry,
|
|
// so that a request for it without a signature is refused, while the same
|
|
// host with no port is allowlisted.
|
|
func TestValidateSignature_HostWithPortNeedsSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker(allowlistedHost, allowlistedIPv4, allowlistedIPv6)
|
|
|
|
tests := []struct {
|
|
host string
|
|
allowlisted bool
|
|
}{
|
|
{allowlistedHost, true},
|
|
{allowlistedHost + ":8443", false},
|
|
{allowlistedHost + ":443", false},
|
|
{allowlistedHost + ":", false},
|
|
{allowlistedIPv4, true},
|
|
{allowlistedIPv4 + ":8080", false},
|
|
{"[" + allowlistedIPv6 + "]", true},
|
|
{"[" + allowlistedIPv6 + "]:8443", false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := h.validateSignature(photoRequest(tt.host), "", time.Time{})
|
|
t.Logf("validateSignature() for host %q with no signature: %v",
|
|
tt.host, err)
|
|
|
|
if allowlisted := err == nil; allowlisted != tt.allowlisted {
|
|
t.Errorf("host %q served without a signature = %v, want %v",
|
|
tt.host, allowlisted, tt.allowlisted)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestValidateSignature_UnbracketedIPv6PortNeedsSignature verifies that an
|
|
// allowlisted IPv6 address written without brackets and followed by a port,
|
|
// or by a colon with no port, needs a signature. Go's URL type reads
|
|
// 2001:db8::1:8443 as the host 2001:db8::1 with the port 8443.
|
|
func TestValidateSignature_UnbracketedIPv6PortNeedsSignature(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
h := newSignatureChecker(allowlistedIPv6)
|
|
|
|
for _, host := range []string{allowlistedIPv6 + ":8443", allowlistedIPv6 + ":"} {
|
|
t.Run(host, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
err := h.validateSignature(photoRequest(host), "", time.Time{})
|
|
t.Logf("validateSignature() for host %q with no signature: %v",
|
|
host, err)
|
|
|
|
if err == nil {
|
|
t.Errorf("host %q served without a signature, want refused",
|
|
host)
|
|
}
|
|
})
|
|
}
|
|
}
|