pluginsdk/plugin/public_routes.go
Alex Dunmow 623ea2e14b feat(manifest): public_routes + sitemap manifest capability (ADR 0026)
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>
2026-07-12 02:41:31 +08:00

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 ""
}