check / check (push) Waiting to run
The host check accepted any hex digits, : and . in brackets, so a host such as [203.0.113.5] or [abc] got through it, and Go's URL parser then refused the source URL, which the route answered with 401. The text in brackets must now parse as an IPv6 address, as Go's URL parser asks, or the host is refused with 400. The comment above the check now says only what is true of it. Model: opus-5-5
354 lines
9.7 KiB
Go
354 lines
9.7 KiB
Go
package imgcache
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"net/netip"
|
|
"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+))?$`)
|
|
|
|
// percentEscapeRegex matches one percent-escape, such as "%2e"
|
|
var percentEscapeRegex = regexp.MustCompile(`%[0-9A-Fa-f]{2}`)
|
|
|
|
// hostRegex matches the forms the host of an image URL may take: a host name
|
|
// or an IPv4 address (letters, digits, "-", "_" and "."), or an IPv6 address
|
|
// in brackets (hex digits, ":" and "."), either one optionally followed by ":"
|
|
// and a port, which may be empty. Its one group holds the text in brackets,
|
|
// which parseImageComponents also checks is an IPv6 address.
|
|
var hostRegex = regexp.MustCompile(
|
|
`^(?:[A-Za-z0-9_.-]+|\[([0-9A-Fa-f:.]+)\])(?::[0-9]*)?$`)
|
|
|
|
// 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) {
|
|
// 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
|
|
}
|
|
|
|
// Only the forms hostRegex describes are accepted. A "?", "#" or "@" in
|
|
// the host would make Go's URL parser, which the allowlist check and the
|
|
// fetcher use, read a different host from the one allowlisted and cached
|
|
hostMatch := hostRegex.FindStringSubmatch(host)
|
|
if hostMatch == nil {
|
|
return nil, fmt.Errorf("%w: %q", ErrInvalidHost, host)
|
|
}
|
|
|
|
// Go's URL parser refuses brackets around anything but an IPv6 address
|
|
if inBrackets := hostMatch[1]; inBrackets != "" {
|
|
addr, err := netip.ParseAddr(inBrackets)
|
|
if err != nil || !addr.Is6() {
|
|
return nil, fmt.Errorf("%w: %q", 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]
|
|
}
|
|
|
|
// The source query names no file on the upstream, so only the host and
|
|
// the source path are checked for path traversal
|
|
err = checkPathTraversal(host + path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// 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 := decodePercentEscapes(decoded)
|
|
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
|
|
}
|
|
|
|
// decodePercentEscapes decodes every percent-escape in s, such as %2e, and
|
|
// keeps a "%" that does not start one as it is, where url.PathUnescape would
|
|
// refuse the whole string. A source path holding %25, a literal "%", has such
|
|
// a "%" after one more decoding.
|
|
func decodePercentEscapes(s string) string {
|
|
return percentEscapeRegex.ReplaceAllStringFunc(s, func(escape string) string {
|
|
decoded, _ := url.PathUnescape(escape)
|
|
|
|
return decoded
|
|
})
|
|
}
|
|
|
|
// 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,
|
|
}
|
|
}
|