Files
pixa/internal/imgcache/urlparser.go
T
sneak f9c878161a
check / check (push) Canceled after 0s
Document how a source query goes into an image URL, and accept it (closes #255)
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
2026-10-15 21:14:59 +00:00

324 lines
8.3 KiB
Go

package imgcache
import (
"errors"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"sneak.berlin/go/pixa/internal/imageprocessor"
)
// URL parsing errors.
var (
ErrInvalidPath = errors.New("invalid image path")
ErrMissingHost = errors.New("missing source host")
ErrInvalidHost = errors.New("invalid source host")
ErrMissingSize = errors.New("missing size specification")
ErrInvalidSize = errors.New("invalid size format")
ErrInvalidFormat = errors.New("invalid or unsupported format")
ErrDimensionTooLarge = errors.New("dimension exceeds maximum")
ErrPathTraversal = errors.New("path traversal detected")
)
// MaxDimension is the maximum allowed width or height, the same as the image
// processor's maximum for an output image.
const MaxDimension = imageprocessor.MaxOutputDimension
// 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",
// and a size with no format, such as "800x600" or "orig"
var sizeFormatRegex = regexp.MustCompile(
`^(\d+)x(\d+)(?:\.(\w+))?$|^(orig)(?:\.(\w+))?$`)
// ParsedURL contains the parsed components of an image proxy URL.
type ParsedURL struct {
// Host is the source origin host (e.g., "cdn.example.com")
Host string
// Path is the path on the origin (e.g., "/photos/cat.jpg")
Path string
// Query is the optional query string for the origin
Query string
// Size is the requested output dimensions
Size Size
// Format is the requested output format
Format ImageFormat
}
// ParseImagePath parses an image URL's path after /v1/image/, decoded once:
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL.
// Examples:
// - cdn.example.com/photos/cat.jpg/800x600.webp
// - cdn.example.com/photos/cat.jpg/0x0.jpeg
// - cdn.example.com/photos/cat.jpg/orig.png
// - cdn.example.com/photos/cat.jpg/800x600
func ParseImagePath(path string) (*ParsedURL, error) {
// Strip leading slash if present (chi may include it)
path = strings.TrimPrefix(path, "/")
if path == "" {
return nil, ErrMissingHost
}
return parseImageComponents(path)
}
// ParseImageURL parses an image URL's path, decoded once as r.URL.Path holds
// it: /v1/image/<host>/<path>/<size>.<format>, or /v1/image/<host>/<path>/<size>
// for JPEG XL. A source query follows the source path after a "?", which the
// URL writes as %3F.
func ParseImageURL(urlPath string) (*ParsedURL, error) {
// Remove the /v1/image/ prefix
const prefix = "/v1/image/"
if !strings.HasPrefix(urlPath, prefix) {
return nil, ErrInvalidPath
}
remainder := strings.TrimPrefix(urlPath, prefix)
if remainder == "" {
return nil, ErrMissingHost
}
return parseImageComponents(remainder)
}
// parseImageComponents parses <host>/<path>/<size>.<format>, or
// <host>/<path>/<size> for JPEG XL.
func parseImageComponents(remainder string) (*ParsedURL, error) {
// Check for path traversal before any other processing
err := checkPathTraversal(remainder)
if err != nil {
return nil, err
}
// Find the last path segment, which holds "size" or "size.format"
lastSlash := strings.LastIndex(remainder, "/")
if lastSlash == -1 {
return nil, ErrMissingSize
}
sizeFormat := remainder[lastSlash+1:]
hostAndPath := remainder[:lastSlash]
if hostAndPath == "" {
return nil, ErrMissingHost
}
// Parse size and format from the last segment
size, format, err := parseSizeFormat(sizeFormat)
if err != nil {
return nil, err
}
// Split host from path
// The first segment is the host, everything after is the path
firstSlash := strings.Index(hostAndPath, "/")
var host, path, query string
if firstSlash == -1 {
// No path, just host (unusual but valid)
host = hostAndPath
path = "/"
} else {
host = hostAndPath[:firstSlash]
path = hostAndPath[firstSlash:]
}
if host == "" {
return nil, ErrMissingHost
}
// A URL reads what comes before "@" as a user name and password, and the
// rest as the host
if strings.Contains(host, "@") {
return nil, fmt.Errorf("%w: %q holds @", ErrInvalidHost, host)
}
// Host names do not depend on letter case, so the allowlist, the upstream
// connection limits, the cache and the signature see one spelling of each
// host
host = strings.ToLower(host)
// Extract query string if present in path
if qIndex := strings.Index(path, "?"); qIndex != -1 {
query = path[qIndex+1:]
path = path[:qIndex]
}
// Ensure path starts with /
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
return &ParsedURL{
Host: host,
Path: path,
Query: query,
Size: size,
Format: format,
}, nil
}
// checkPathTraversal detects path traversal attempts in a URL path.
// It checks for various attack vectors including:
// - Direct ../ sequences
// - URL-encoded variants (%2e%2e, %252e%252e)
// - Backslash variants (..\)
// - Null byte injection (%00)
func checkPathTraversal(path string) error {
// First, URL-decode the path to catch encoded attacks
// Decode multiple times to catch double-encoding
decoded := path
for range 3 {
newDecoded, err := url.PathUnescape(decoded)
if err != nil {
// Malformed encoding is suspicious
return ErrPathTraversal
}
if newDecoded == decoded {
break
}
decoded = newDecoded
}
// Normalize backslashes to forward slashes
normalized := strings.ReplaceAll(decoded, "\\", "/")
// Check for null bytes
if strings.Contains(normalized, "\x00") {
return ErrPathTraversal
}
// Check for parent directory traversal
// Look for "/.." or "../" patterns
if strings.Contains(normalized, "/../") ||
strings.Contains(normalized, "/..") ||
strings.HasPrefix(normalized, "../") ||
strings.HasSuffix(normalized, "/..") ||
normalized == ".." {
return ErrPathTraversal
}
// Also check for ".." as a path segment in the original path
// This catches cases where the path hasn't been normalized
for seg := range strings.SplitSeq(path, "/") {
// URL decode the segment
decodedSeg, _ := url.PathUnescape(seg)
decodedSeg = strings.ReplaceAll(decodedSeg, "\\", "/")
if decodedSeg == ".." {
return ErrPathTraversal
}
}
return nil
}
// parseSizeFormat parses strings like "800x600.webp", "orig.png" or "800x600"
func parseSizeFormat(s string) (Size, ImageFormat, error) {
matches := sizeFormatRegex.FindStringSubmatch(s)
if matches == nil {
return Size{}, "", ErrInvalidSize
}
var (
size Size
formatStr string
)
if matches[4] == "orig" {
// "orig" or "orig.format" pattern
size = Size{Width: 0, Height: 0}
formatStr = matches[5]
} else {
// "WxH" or "WxH.format" pattern
width, err := strconv.Atoi(matches[1])
if err != nil {
return Size{}, "", ErrInvalidSize
}
height, err := strconv.Atoi(matches[2])
if err != nil {
return Size{}, "", ErrInvalidSize
}
size = Size{Width: width, Height: height}
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
}
// A URL that names no format is served, and signed, as JPEG XL
if formatStr == "" {
return size, FormatJXL, nil
}
format, err := parseFormat(formatStr)
if err != nil {
return Size{}, "", err
}
return size, format, nil
}
// parseFormat converts a format string to ImageFormat.
func parseFormat(s string) (ImageFormat, error) {
switch strings.ToLower(s) {
case "orig", "original":
return FormatOriginal, nil
case "jpg", "jpeg":
return FormatJPEG, nil
case "png":
return FormatPNG, nil
case "webp":
return FormatWebP, nil
case "avif":
return FormatAVIF, nil
case "jxl":
return FormatJXL, nil
case "gif":
return FormatGIF, nil
case "auto":
return FormatAuto, nil
default:
return "", fmt.Errorf("%w: %s", ErrInvalidFormat, s)
}
}
// ToImageRequest converts a ParsedURL to an ImageRequest.
func (p *ParsedURL) ToImageRequest() *ImageRequest {
return &ImageRequest{
SourceHost: p.Host,
SourcePath: p.Path,
SourceQuery: p.Query,
Size: p.Size,
Format: p.Format,
}
}