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