check / check (push) Canceled after 0s
The query follows the source path after %3F. The image route now parses the request's path decoded once instead of chi's wildcard, which holds the path as the client wrote it whenever that is not Go's own encoding of it, so a / ; or , written as %2F, %3B or %2C, as GenerateSignedURL wrote them, hid the %3F and the request failed with 401. Now the ? and every % in the source path or query must be percent-encoded and any other character may be. GenerateSignedURL writes Go's own encoding of the path. The README states the rule, with an example and its signature. Model: opus-5-5
188 lines
5.8 KiB
Go
188 lines
5.8 KiB
Go
// Package signature provides HMAC-SHA256 signing and verification of image
|
|
// requests.
|
|
package signature
|
|
|
|
import (
|
|
"crypto/hmac"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"errors"
|
|
"fmt"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Signature errors.
|
|
var (
|
|
ErrRequired = errors.New("signature required for non-allowlisted host")
|
|
ErrInvalid = errors.New("invalid signature")
|
|
ErrExpired = errors.New("signature has expired")
|
|
ErrMissingExpiration = errors.New("signature expiration is required")
|
|
)
|
|
|
|
// Request carries the components an image request signature covers. It is a
|
|
// standalone type so that this package does not depend on imgcache, keeping
|
|
// the import edge one-way (imgcache depends on signature, never the reverse).
|
|
type Request struct {
|
|
// SourceHost is the origin host (e.g. "cdn.example.com").
|
|
SourceHost string
|
|
// SourcePath is the path on the origin (e.g. "/photos/cat.jpg").
|
|
SourcePath string
|
|
// SourceQuery is the optional query string for the origin URL.
|
|
SourceQuery string
|
|
// Width is the requested output width in pixels.
|
|
Width int
|
|
// Height is the requested output height in pixels.
|
|
Height int
|
|
// Format is the requested output format (e.g. "webp").
|
|
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 string
|
|
// Expires is the signature expiration timestamp.
|
|
Expires time.Time
|
|
}
|
|
|
|
// Signer handles HMAC-SHA256 signature generation and verification.
|
|
type Signer struct {
|
|
secretKey []byte
|
|
}
|
|
|
|
// New creates a new Signer with the given secret key.
|
|
func New(secretKey string) *Signer {
|
|
return &Signer{
|
|
secretKey: []byte(secretKey),
|
|
}
|
|
}
|
|
|
|
// Sign generates an HMAC-SHA256 signature for the given request.
|
|
// The signature covers: host + path + query + width + height + format +
|
|
// expiration + quality + fit.
|
|
func (s *Signer) Sign(req *Request) string {
|
|
data := s.buildSignatureData(req)
|
|
mac := hmac.New(sha256.New, s.secretKey)
|
|
mac.Write([]byte(data))
|
|
sig := mac.Sum(nil)
|
|
|
|
return base64.URLEncoding.EncodeToString(sig)
|
|
}
|
|
|
|
// Verify checks if the signature on the request is valid and not expired.
|
|
// Signatures are exact-match only: every component of the signed data
|
|
// (host, path, query, dimensions, format, expiration, quality, fit) must
|
|
// match exactly.
|
|
// No suffix matching, wildcard matching, or partial matching is supported.
|
|
// A signature for "cdn.example.com" will NOT verify for "example.com" or
|
|
// "other.cdn.example.com", and vice versa.
|
|
func (s *Signer) Verify(req *Request) error {
|
|
// Check expiration first
|
|
if req.Expires.IsZero() {
|
|
return ErrMissingExpiration
|
|
}
|
|
|
|
if time.Now().After(req.Expires) {
|
|
return ErrExpired
|
|
}
|
|
|
|
// Compute expected signature
|
|
expected := s.Sign(req)
|
|
|
|
// Constant-time comparison to prevent timing attacks
|
|
if !hmac.Equal([]byte(req.Signature), []byte(expected)) {
|
|
return ErrInvalid
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// GenerateSignedURL creates a complete URL with signature and expiration.
|
|
// Returns the path portion that should be appended to the base URL.
|
|
func (s *Signer) GenerateSignedURL(
|
|
req *Request, ttl time.Duration,
|
|
) (string, string, int64) {
|
|
// Set expiration
|
|
req.Expires = time.Now().Add(ttl)
|
|
exp := req.Expires.Unix()
|
|
|
|
// Generate signature
|
|
sig := s.Sign(req)
|
|
req.Signature = sig
|
|
|
|
// Build the size component
|
|
var sizeStr string
|
|
if req.Width == 0 && req.Height == 0 {
|
|
sizeStr = "orig"
|
|
} else {
|
|
sizeStr = fmt.Sprintf("%dx%d", req.Width, req.Height)
|
|
}
|
|
|
|
// Build the path. A source query follows the source path after a "?".
|
|
// The image route reads the path decoded once, so the whole path is
|
|
// written in Go's own percent-encoding of a URL path, which writes "?"
|
|
// as %3F and "%" as %25, and leaves "/", ";", ",", "=" and "&" as they
|
|
// are.
|
|
sourcePathAndQuery := req.SourcePath
|
|
if req.SourceQuery != "" {
|
|
sourcePathAndQuery += "?" + req.SourceQuery
|
|
}
|
|
|
|
unescapedPath := fmt.Sprintf("/v1/image/%s%s/%s.%s",
|
|
req.SourceHost, sourcePathAndQuery, sizeStr, req.Format)
|
|
|
|
return (&url.URL{Path: unescapedPath}).EscapedPath(), sig, exp
|
|
}
|
|
|
|
// buildSignatureData creates the string to be signed.
|
|
// Format: "host:path:query:width:height:format:expiration:quality:fit"
|
|
// The path and query go through escapePercentAndColon, so that a ":" in
|
|
// either cannot be taken for the one between them; every other component
|
|
// is used verbatim. No normalization, 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 {
|
|
return fmt.Sprintf("%s:%s:%s:%d:%d:%s:%d:%d:%s",
|
|
req.SourceHost,
|
|
escapePercentAndColon(req.SourcePath),
|
|
escapePercentAndColon(req.SourceQuery),
|
|
req.Width,
|
|
req.Height,
|
|
req.Format,
|
|
req.Expires.Unix(),
|
|
req.Quality,
|
|
req.FitMode,
|
|
)
|
|
}
|
|
|
|
// escapePercentAndColon returns s with each "%" written as "%25" and each
|
|
// ":" as "%3A", so that the result holds no ":".
|
|
func escapePercentAndColon(s string) string {
|
|
s = strings.ReplaceAll(s, "%", "%25")
|
|
|
|
return strings.ReplaceAll(s, ":", "%3A")
|
|
}
|
|
|
|
// ParseParams extracts signature and expiration from query parameters.
|
|
func ParseParams(sig, expStr string) (string, time.Time, error) {
|
|
if expStr == "" {
|
|
return sig, time.Time{}, nil
|
|
}
|
|
|
|
expUnix, err := strconv.ParseInt(expStr, 10, 64)
|
|
if err != nil {
|
|
return "", time.Time{}, fmt.Errorf("invalid expiration: %w", err)
|
|
}
|
|
|
|
return sig, time.Unix(expUnix, 0), nil
|
|
}
|