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) } }) } }