Files
wanderer/db/pluginsystem/policy.go
slothful-vassal 485ec53f6d feat: add plugin system (#1034)
* feat: add plugin system

* fix db docker build

* fix hammerhead readme, add strava subscription news to docs

* fixes and sdk improvements

* fix: reduce Meilisearch load, debounce federation sync (#1012)

* optimize meili trail index

* several fixes

---------

Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Bump svelte from 5.55.5 to 5.56.0 in /docs (#1032)

Bumps [svelte](https://github.com/sveltejs/svelte/tree/HEAD/packages/svelte) from 5.55.5 to 5.56.0.
- [Release notes](https://github.com/sveltejs/svelte/releases)
- [Changelog](https://github.com/sveltejs/svelte/blob/main/packages/svelte/CHANGELOG.md)
- [Commits](https://github.com/sveltejs/svelte/commits/svelte@5.56.0/packages/svelte)

---
updated-dependencies:
- dependency-name: svelte
  dependency-version: 5.56.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>

* Release v0.19.2 (#1035)

* chore: release v0.19.2

* add changelog

---------

Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
Co-authored-by: Christian Beutel <>

* speed up plugin sync and several small fixes

* concepts for security improvements and process stability

* improve concept

* security concept implemented

* remove insecure TLS

* worker concept implemented

* fixes and cleanup

* fixes

* docu

* mermaid, namings

* WASM plugin host improvements, plugin logging

* fix db migration

* Improve plugin config and category mapping UI

* fixes

* further fixes

* remove manual test sync

* fix db migration and strava mapping

* type added, UI improvements

* fix plugin card toggle clickable area

* optimize synch status card layout

* plugin type 'trails' instead of 'integration'

* session auth validation in UI

* fix komoot date and waypoints

* improve category mapping

* fix send to hammerhead: trail name

* plugin setup error handling improved

* fix review findings

* re-mapping added

* rename remote_category

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: Flomp <Flomp@users.noreply.github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Flomp <26000991+Flomp@users.noreply.github.com>
2026-06-22 15:00:44 +02:00

418 lines
13 KiB
Go

package pluginsystem
import (
"fmt"
"net/url"
"path"
"slices"
"strings"
)
const (
ConnectorTypePublicAPI = "public_api"
ConnectorTypeConfigured = "configured"
TLSModeSystem = "system"
TLSModeCustomCA = "customCA"
)
type RequestPolicyContext struct {
Connectors map[string]ResolvedConnectorTarget
HostAuth map[string]any
}
func (p RequestPolicyContext) WithHostAuth(auth map[string]any) RequestPolicyContext {
p.HostAuth = auth
return p
}
type ResolvedConnectorTarget struct {
Name string
Type string
BaseURL string
BasePath string
AllowPrivate bool
TLS ConnectorTLSConfig
StorageOrigins map[string]ResolvedConnectorOrigin
AllowedPathPrefixes []string
Auth []string
SupportsMediaAuth bool
SupportsStorageRedirects bool
SupportsCustomTLS bool
}
type ConnectorTLSConfig struct {
Mode string
CABundle []byte
}
type ResolvedConnectorOrigin struct {
Name string
BaseURL string
BasePath string
AllowPrivate bool
TLS ConnectorTLSConfig
}
type ResolvedRequestTarget struct {
URL *url.URL
Connector ResolvedConnectorTarget
}
// ValidateHostRequestSpec checks the static manifest policy before the host
// performs any plugin-controlled HTTP request. Provider traffic must use a
// connector target; plugins no longer hand the host absolute API URLs.
func ValidateHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) error {
_, err := ValidateAndResolveHostRequestSpec(manifest, spec, policy)
return err
}
func ValidateAndResolveHostRequestSpec(manifest Manifest, spec HostRequestSpec, policy RequestPolicyContext) (*ResolvedRequestTarget, error) {
if strings.TrimSpace(spec.Method) == "" {
return nil, fmt.Errorf("method is required")
}
resolved, err := ResolveRequestTarget(manifest, spec.Target, policy)
if err != nil {
return nil, err
}
if spec.Auth != "" {
if err := ValidateAuthReference(manifest, spec.Auth); err != nil {
return nil, err
}
if len(resolved.Connector.Auth) > 0 && !slices.Contains(resolved.Connector.Auth, spec.Auth) {
return nil, fmt.Errorf("auth context %q is not permitted for connector %q", spec.Auth, resolved.Connector.Name)
}
}
if err := validateExpectedResponse(spec.Expect, manifest.Permissions.Downloads); err != nil {
return nil, err
}
return resolved, nil
}
func ValidateAuthReference(manifest Manifest, auth string) error {
if _, ok := manifest.Auth.Contexts[auth]; !ok {
return fmt.Errorf("auth context %q is not declared", auth)
}
if !slices.Contains(manifest.Permissions.Auth, auth) {
return fmt.Errorf("auth context %q is not permitted", auth)
}
return nil
}
func ResolveRequestTarget(manifest Manifest, target RequestTarget, policy RequestPolicyContext) (*ResolvedRequestTarget, error) {
if target.Type != "connector" {
return nil, fmt.Errorf("request target type must be connector")
}
connector, ok := policy.Connectors[target.Connector]
if !ok {
return nil, fmt.Errorf("connector %q is not configured", target.Connector)
}
manifestConnector, ok := manifestConnector(manifest, target.Connector)
if !ok {
return nil, fmt.Errorf("connector %q is not declared by manifest", target.Connector)
}
connector.AllowedPathPrefixes = canonicalConnectorPrefixes(manifestConnector.AllowedPathPrefixes)
connector.Auth = manifestConnector.Auth
connector.SupportsMediaAuth = manifestConnector.SupportsMediaAuth
connector.SupportsStorageRedirects = manifestConnector.SupportsStorageRedirects
connector.SupportsCustomTLS = manifestConnector.SupportsCustomTLS
built, err := BuildConnectorURL(connector, target.Path, target.Query)
if err != nil {
return nil, err
}
if err := ValidateConnectorURL(connector, built); err != nil {
return nil, err
}
return &ResolvedRequestTarget{URL: built, Connector: connector}, nil
}
func manifestConnector(manifest Manifest, name string) (ConnectorTargetPermission, bool) {
for _, connector := range manifest.Permissions.Network.Connectors {
if connector.Name == name {
return connector, true
}
}
return ConnectorTargetPermission{}, false
}
func BuildConnectorURL(connector ResolvedConnectorTarget, relPath string, query []QueryParam) (*url.URL, error) {
base, err := url.Parse(connector.BaseURL)
if err != nil || base.Scheme == "" || base.Host == "" {
return nil, fmt.Errorf("connector %q has invalid baseURL", connector.Name)
}
if base.RawQuery != "" || base.Fragment != "" {
return nil, fmt.Errorf("connector %q baseURL must not include query or fragment", connector.Name)
}
if base.Scheme != "http" && base.Scheme != "https" {
return nil, fmt.Errorf("connector %q scheme must be http or https", connector.Name)
}
base.Path = ""
base.RawPath = ""
cleanBase, err := CanonicalURLPath(connector.BasePath)
if err != nil {
return nil, fmt.Errorf("connector %q basePath: %w", connector.Name, err)
}
cleanRel, err := CanonicalRelativeURLPath(relPath)
if err != nil {
return nil, err
}
fullPath := joinURLPaths(cleanBase, cleanRel)
if strings.HasSuffix(cleanRel, "/") && fullPath != "/" {
fullPath += "/"
}
base.Path = fullPath
encodedQuery := make([]string, 0, len(query))
for _, param := range query {
if hasControl(param.Name) || hasControl(param.Value) {
return nil, fmt.Errorf("query parameters must not contain control characters")
}
if param.Name == "" {
return nil, fmt.Errorf("query parameter name must not be empty")
}
encodedQuery = append(encodedQuery, url.QueryEscape(param.Name)+"="+url.QueryEscape(param.Value))
}
base.RawQuery = strings.Join(encodedQuery, "&")
return base, nil
}
func ValidateConnectorURL(connector ResolvedConnectorTarget, candidate *url.URL) error {
base, err := url.Parse(connector.BaseURL)
if err != nil {
return err
}
if !strings.EqualFold(candidate.Scheme, base.Scheme) {
return fmt.Errorf("connector request scheme escaped scope")
}
if !strings.EqualFold(candidate.Hostname(), base.Hostname()) {
return fmt.Errorf("connector request host escaped scope")
}
if effectivePort(candidate) != effectivePort(base) {
return fmt.Errorf("connector request port escaped scope")
}
candidatePath, err := CanonicalURLPath(candidate.EscapedPath())
if err != nil {
return err
}
basePath, err := CanonicalURLPath(connector.BasePath)
if err != nil {
return err
}
if !pathInPrefix(candidatePath, subtreePrefix(basePath)) {
return fmt.Errorf("connector request escaped base path")
}
prefixes := connector.AllowedPathPrefixes
if len(prefixes) == 0 {
prefixes = []string{"/"}
}
for _, prefix := range canonicalConnectorPrefixes(prefixes) {
fullPrefix := subtreePrefix(joinURLPaths(basePath, prefix))
if pathInPrefix(candidatePath, fullPrefix) {
return nil
}
}
return fmt.Errorf("connector request path is not allowed")
}
func ValidateConnectorRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error {
if initial.Scheme == "https" && redirected.Scheme == "http" {
return fmt.Errorf("connector redirect downgrades https to http")
}
return ValidateConnectorURL(connector, redirected)
}
func ValidateConnectorStorageRedirect(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) error {
_, err := ConnectorStorageRedirectOrigin(connector, initial, redirected)
return err
}
func ConnectorStorageRedirectOrigin(connector ResolvedConnectorTarget, initial *url.URL, redirected *url.URL) (ResolvedConnectorOrigin, error) {
if !connector.SupportsStorageRedirects {
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirects are not supported")
}
if initial.Scheme == "https" && redirected.Scheme == "http" {
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect downgrades https to http")
}
for _, origin := range connector.StorageOrigins {
target := ResolvedConnectorTarget{
Name: origin.Name,
BaseURL: origin.BaseURL,
BasePath: origin.BasePath,
AllowPrivate: origin.AllowPrivate,
TLS: origin.TLS,
AllowedPathPrefixes: []string{"/"},
}
if err := ValidateConnectorURL(target, redirected); err == nil {
return origin, nil
}
}
return ResolvedConnectorOrigin{}, fmt.Errorf("connector storage redirect target is not allowed")
}
func NormalizeConnectorBase(rawURL string, extraBasePath string) (string, string, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return "", "", fmt.Errorf("connector baseURL is invalid")
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", "", fmt.Errorf("connector baseURL scheme must be http or https")
}
if parsed.RawQuery != "" || parsed.Fragment != "" || parsed.User != nil {
return "", "", fmt.Errorf("connector baseURL must not include credentials, query, or fragment")
}
basePath := parsed.EscapedPath()
if extraBasePath != "" {
basePath = joinURLPaths(basePath, extraBasePath)
}
cleanPath, err := CanonicalURLPath(basePath)
if err != nil {
return "", "", err
}
parsed.Path = ""
parsed.RawPath = ""
return parsed.String(), cleanPath, nil
}
func CanonicalRelativeURLPath(rawPath string) (string, error) {
if strings.TrimSpace(rawPath) == "" {
return "/", nil
}
if strings.HasPrefix(rawPath, "http://") || strings.HasPrefix(rawPath, "https://") || strings.HasPrefix(rawPath, "//") {
return "", fmt.Errorf("connector path must be relative")
}
cleaned, err := CanonicalURLPath("/" + strings.TrimLeft(rawPath, "/"))
if err != nil {
return "", err
}
if strings.HasSuffix(rawPath, "/") && cleaned != "/" {
cleaned += "/"
}
return cleaned, nil
}
func CanonicalURLPath(rawPath string) (string, error) {
if rawPath == "" {
rawPath = "/"
}
if hasControl(rawPath) {
return "", fmt.Errorf("path must not contain control characters")
}
lower := strings.ToLower(rawPath)
if strings.Contains(lower, "%2f") || strings.Contains(lower, "%5c") {
return "", fmt.Errorf("encoded path separators are not allowed")
}
decoded, err := url.PathUnescape(rawPath)
if err != nil {
return "", fmt.Errorf("path has invalid escapes")
}
if strings.Contains(decoded, "\\") {
return "", fmt.Errorf("backslash is not allowed in URL paths")
}
if hasDangerousSecondEscape(decoded) {
return "", fmt.Errorf("ambiguous encoded path is not allowed")
}
cleaned := path.Clean("/" + strings.TrimLeft(decoded, "/"))
if cleaned == "." {
cleaned = "/"
}
return cleaned, nil
}
func canonicalConnectorPrefixes(prefixes []string) []string {
if len(prefixes) == 0 {
return nil
}
canonical := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
cleaned, err := CanonicalURLPath(prefix)
if err == nil {
canonical = append(canonical, cleaned)
}
}
return canonical
}
func joinURLPaths(left string, right string) string {
if left == "" {
left = "/"
}
if right == "" {
right = "/"
}
joined := path.Join(left, right)
if joined == "." {
return "/"
}
if !strings.HasPrefix(joined, "/") {
joined = "/" + joined
}
return joined
}
func subtreePrefix(prefix string) string {
if prefix == "/" {
return "/"
}
return strings.TrimRight(prefix, "/") + "/"
}
func pathInPrefix(candidate string, prefix string) bool {
if prefix == "/" {
return true
}
candidate = subtreePrefix(candidate)
return strings.HasPrefix(candidate, prefix)
}
func effectivePort(u *url.URL) string {
if port := u.Port(); port != "" {
return port
}
switch u.Scheme {
case "http":
return "80"
case "https":
return "443"
default:
return ""
}
}
func hasControl(value string) bool {
for _, r := range value {
if r < 0x20 || r == 0x7f {
return true
}
}
return false
}
func hasDangerousSecondEscape(value string) bool {
lower := strings.ToLower(value)
for _, marker := range []string{"%2f", "%5c", "%2e"} {
if strings.Contains(lower, marker) {
return true
}
}
return false
}
// validateExpectedResponse lets a plugin request stricter response checks for a
// specific call while preventing it from exceeding manifest download limits.
func validateExpectedResponse(expect ResponseExpect, permissions DownloadPermissions) error {
if expect.MaxBytes < 0 {
return fmt.Errorf("expect.maxBytes must not be negative")
}
if permissions.MaxBytes > 0 && expect.MaxBytes > permissions.MaxBytes {
return fmt.Errorf("expect.maxBytes exceeds manifest download limit")
}
for _, contentType := range expect.ContentTypes {
if len(permissions.ContentTypes) > 0 && !slices.Contains(permissions.ContentTypes, contentType) {
return fmt.Errorf("content type %q is not allowed by manifest permissions", contentType)
}
}
return nil
}