Plugins declare site-root path claims (exact or prefix) and a sitemap contribution flag in plugin.mod; the packer validates and stamps them into PluginManifest. Validation rejects reserved prefixes (/admin, /api/plugins, /ws), traversal, duplicates, and prefix claims that cover a reserved prefix. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
91 lines
2.9 KiB
Go
91 lines
2.9 KiB
Go
package plugin
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
)
|
|
|
|
// MaxPublicRoutes caps the number of site-root claims a single plugin may
|
|
// declare. Generous: real plugins claim a handful of SEO prefixes.
|
|
const MaxPublicRoutes = 64
|
|
|
|
// ReservedRoutePrefixes are site-root prefixes plugins may never claim: the
|
|
// admin SPA, the namespaced plugin HTTP mount, and the realtime websocket.
|
|
// A claim equal to or under one of these is rejected, as is a prefix claim
|
|
// that would cover one (e.g. prefix "/api" covers "/api/plugins").
|
|
var ReservedRoutePrefixes = []string{"/admin", "/api/plugins", "/ws"}
|
|
|
|
// routeSegmentRe matches one path segment: URL-safe unreserved characters.
|
|
var routeSegmentRe = regexp.MustCompile(`^[A-Za-z0-9._~-]+$`)
|
|
|
|
// ValidatePublicRoutes checks a plugin.mod public_routes declaration. It
|
|
// returns an error listing every offending entry so authors fix them in one
|
|
// pass.
|
|
//
|
|
// Rules:
|
|
// - at most MaxPublicRoutes entries
|
|
// - each path is absolute ("/..."), not the site root "/" itself
|
|
// - segments are non-empty (no "//", no trailing "/"), never "." or "..",
|
|
// and use URL-safe unreserved characters only
|
|
// - no claim equal to or under a reserved prefix (ReservedRoutePrefixes),
|
|
// and no prefix claim that covers a reserved prefix
|
|
// - no duplicate paths
|
|
func ValidatePublicRoutes(routes []ModPublicRoute) error {
|
|
if len(routes) > MaxPublicRoutes {
|
|
return fmt.Errorf("too many public_routes: got %d, max %d", len(routes), MaxPublicRoutes)
|
|
}
|
|
seen := make(map[string]struct{}, len(routes))
|
|
var bad []string
|
|
for _, r := range routes {
|
|
if reason := checkRoutePath(r); reason != "" {
|
|
bad = append(bad, fmt.Sprintf("%q (%s)", r.Path, reason))
|
|
continue
|
|
}
|
|
if _, dup := seen[r.Path]; dup {
|
|
bad = append(bad, fmt.Sprintf("%q (duplicate path)", r.Path))
|
|
continue
|
|
}
|
|
seen[r.Path] = struct{}{}
|
|
}
|
|
if len(bad) > 0 {
|
|
return fmt.Errorf("invalid public_routes: %s", strings.Join(bad, "; "))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func checkRoutePath(r ModPublicRoute) string {
|
|
p := r.Path
|
|
if p == "" {
|
|
return "empty path"
|
|
}
|
|
if !strings.HasPrefix(p, "/") {
|
|
return "path must be absolute, starting with /"
|
|
}
|
|
if p == "/" {
|
|
return "cannot claim the site root"
|
|
}
|
|
if strings.HasSuffix(p, "/") {
|
|
return "no trailing slash; set prefix = true to claim the subtree"
|
|
}
|
|
for _, seg := range strings.Split(p[1:], "/") {
|
|
switch {
|
|
case seg == "":
|
|
return "empty path segment"
|
|
case seg == "." || seg == "..":
|
|
return "path traversal segment"
|
|
case !routeSegmentRe.MatchString(seg):
|
|
return fmt.Sprintf("segment %q has characters outside [A-Za-z0-9._~-]", seg)
|
|
}
|
|
}
|
|
for _, reserved := range ReservedRoutePrefixes {
|
|
if p == reserved || strings.HasPrefix(p, reserved+"/") {
|
|
return fmt.Sprintf("reserved prefix %s", reserved)
|
|
}
|
|
if r.Prefix && strings.HasPrefix(reserved, p+"/") {
|
|
return fmt.Sprintf("prefix claim covers reserved prefix %s", reserved)
|
|
}
|
|
}
|
|
return ""
|
|
}
|