Watch
1
0
Fork
You've already forked pkg-proxy
1
mirror of https://github.com/git-pkgs/proxy.git synced 2026-08-23 12:24:57 -04:00
pkg-proxy/internal/server/resolve.go
wickedOne 849500de1e
fix: decode PURL percent-encoding in versions and package paths (#244)
* fix: decode PURL percent-encoding in versions and package paths

* review fix
2026-08-14 10:38:08 +01:00

146 lines
5.3 KiB
Go

package server
import (
"fmt"
"net/http"
"net/url"
"strings"
"unicode"
"github.com/git-pkgs/proxy/internal/database"
"github.com/go-chi/chi/v5"
)
// maxPackagePathLen bounds the wildcard portion of package routes (name plus
// version and any suffix). npm caps names at 214 and Maven coordinates can be
// longer, so 512 leaves room without admitting pathological inputs.
const maxPackagePathLen = 512
// packagePathSegments validates the wildcard portion of a package route and
// splits it into decoded path segments.
func packagePathSegments(r *http.Request) ([]string, error) {
wildcard := chi.URLParam(r, "*")
encoded := wildcardIsEncoded(r)
if err := validatePackagePath(wildcard, encoded); err != nil {
return nil, err
}
return splitWildcardPath(wildcard, encoded), nil
}
// wildcardIsEncoded reports whether the chi wildcard for this request is still
// percent-encoded.
//
// chi routes on r.URL.RawPath when it is set and on r.URL.Path otherwise, and
// net/url only sets RawPath when the request's escaping differs from the
// canonical encoding of the decoded path. A version such as "release%2F1" is
// therefore routed raw, while "1.0%252B" (a version whose text contains a
// literal "%2B") encodes canonically and arrives already decoded once. The
// distinction decides whether the segments still need decoding: decoding the
// second case again would turn it into "1.0+" and resolve a different version.
func wildcardIsEncoded(r *http.Request) bool {
return r.URL.RawPath != ""
}
// validatePackagePath rejects wildcard package paths that cannot be valid in
// any supported ecosystem. It is a coarse filter applied before database or
// enrichment lookups; ecosystem-specific name rules are layered on top.
//
// encoded has the meaning described on wildcardIsEncoded.
func validatePackagePath(path string, encoded bool) error {
if path == "" {
return fmt.Errorf("package name required")
}
if len(path) > maxPackagePathLen {
return fmt.Errorf("package path exceeds %d bytes", maxPackagePathLen)
}
// Validate the decoded segments: the handlers work with decoded values, so
// an escape such as "%00" or "%2E%2E" must not slip past these checks.
for _, seg := range splitWildcardPath(path, encoded) {
// Each segment is checked both as the handlers see it and decoded once
// more: a segment can reach a handler with escapes intact, and the
// upstream registry is then the one that decodes them.
for _, value := range []string{seg, decodePathSegment(seg)} {
// A decoded segment can itself contain slashes (from "%2F"), and
// the segments are later rejoined into a package name that
// registries interpolate straight into an upstream URL. Check every
// path element, not just the segment as a whole, or
// "a%2F..%2F..%2Fb" traverses.
for _, elem := range strings.Split(value, "/") {
if elem == ".." {
return fmt.Errorf("package path contains parent directory segment")
}
}
for _, r := range value {
if r == 0 {
return fmt.Errorf("package path contains null byte")
}
if unicode.IsControl(r) {
return fmt.Errorf("package path contains control character %#U", r)
}
}
}
}
return nil
}
// resolvePackageName determines the package name from a wildcard path by
// checking the database. This handles namespaced packages like Composer's
// vendor/name format where the package name contains a slash.
//
// It tries the full path as a package name first. If not found, it splits
// off the last segment as a non-name suffix (version, action, etc.) and
// tries again, working backwards until a match is found or segments run out.
//
// Returns the package name and the remaining path segments after the name.
// If no package is found, returns empty name and the original segments.
func resolvePackageName(db *database.DB, ecosystem string, segments []string) (name string, rest []string) {
// Try increasingly longer prefixes as the package name.
// Start with the longest possible name (all segments) and work down.
for i := len(segments); i >= 1; i-- {
candidate := strings.Join(segments[:i], "/")
pkg, err := db.GetPackageByEcosystemName(ecosystem, candidate)
if err == nil && pkg != nil {
return candidate, segments[i:]
}
}
return "", segments
}
// splitWildcardPath splits a chi wildcard path value into segments,
// trimming any leading/trailing slashes.
//
// When encoded is set the value is still percent-encoded (see
// wildcardIsEncoded), so each segment is decoded after splitting. Splitting
// first keeps an encoded "%2F" inside a name from being mistaken for a
// separator. Decoding matters for versions such as "1.0%2Bbuild1", which must
// reach the handlers as "1.0+build1" so that rebuilding the PURL yields the
// value that was stored rather than a double-encoded one.
func splitWildcardPath(path string, encoded bool) []string {
path = strings.Trim(path, "/")
if path == "" {
return nil
}
segments := strings.Split(path, "/")
if !encoded {
return segments
}
for i, seg := range segments {
segments[i] = decodePathSegment(seg)
}
return segments
}
// decodePathSegment percent-decodes a single URL path segment, returning it
// unchanged if it is not valid percent-encoding.
func decodePathSegment(seg string) string {
if !strings.Contains(seg, "%") {
return seg
}
decoded, err := url.PathUnescape(seg)
if err != nil {
return seg
}
return decoded
}