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}`) // queryEscapeRegex matches a percent-escape, or one character that a browser // percent-encodes in a URL's query: one outside the printable ASCII characters // "!" to "~", such as a space, a control character or a character outside // ASCII, or one of `"#<>`. A "%" that does not begin a percent-escape matches too. var queryEscapeRegex = 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: // //., or // 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///., or /v1/image/// // 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 //., or // // 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) // Go's URL parser, which the fetcher uses, reads a "#" as the start of a // fragment and drops the rest, so each text after it would be another // spelling, cached under its own keys, of one upstream request if strings.Contains(path, "#") { return nil, fmt.Errorf( `%w: the source path or query holds a "#"`, ErrInvalidPath) } // Extract query string if present in path. It is the source URL's query // as that URL writes it. A character a URL's query cannot hold as it is, // such as a space written %20 in the image URL, is percent-encoded here, // so the query is signed and cached as it is sent upstream if qIndex := strings.Index(path, "?"); qIndex != -1 { query = escapeQuery(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 }) } // escapeQuery returns a source query as a URL's query holds it: each // character that queryEscapeRegex names, such as a space, percent-encoded, // and the rest, the query's percent-escapes included, kept as they are. The // result is unchanged by a second escapeQuery. func escapeQuery(query string) string { return queryEscapeRegex.ReplaceAllStringFunc(query, func(match string) string { if percentEscapeRegex.MatchString(match) { return match } return url.PathEscape(match) }) } // 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, } }