Files
pixa/internal/imgcache/urlparser.go
T
clawbot d8760c40a1
check / check (push) Waiting to run
Refuse a size whose computed side exceeds the maximum (closes #241)
A side given as 0 is computed from the source's proportions, and
nothing held it to 8192: 0x8192 of a 3000x600 source was resized to
40960x8192 and encoded. Process now refuses such a size with
ErrOutputTooLarge once it knows the source's dimensions, before the
resize and the encode, and both image routes answer it 400 with the
JSON error body. It is a processing error, not a failed fetch, so it
is never negative-cached. imgcache.MaxDimension is now
imageprocessor.MaxOutputDimension, so the request limit and the output
limit are one value. README.md says it under Routes and Encrypted URLs.

Model: opus-5-5
2026-10-13 09:44:44 +02:00

312 lines
7.8 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")
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 the path captured by chi's wildcard:
// <host>/<path>/<size>.<format>, or <host>/<path>/<size> for JPEG XL
// This is the primary entry point when using chi routing.
// 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 a full URL path like /v1/image/<host>/<path>/<size>.<format>,
// or /v1/image/<host>/<path>/<size> for JPEG XL
// Use ParseImagePath instead when working with chi's wildcard capture.
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
}
// 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,
}
}