* 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>
227 lines
5.4 KiB
Go
227 lines
5.4 KiB
Go
package util
|
|
|
|
import (
|
|
"context"
|
|
"crypto/tls"
|
|
"crypto/x509"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/netip"
|
|
"net/url"
|
|
"time"
|
|
|
|
"github.com/doyensec/safeurl"
|
|
)
|
|
|
|
const (
|
|
DefaultPluginMediaMaxBytes int64 = 50 << 20
|
|
DefaultPluginMaxImportMediaItems = 20
|
|
DefaultPluginMaxImportMediaBytes int64 = 200 << 20
|
|
)
|
|
|
|
type SafeFetchResult struct {
|
|
Body []byte
|
|
ContentType string
|
|
FinalURL string
|
|
}
|
|
|
|
type ConnectorHTTPPolicy struct {
|
|
BaseURL string
|
|
AllowPrivate bool
|
|
TLSMode string
|
|
TLSCABundle []byte
|
|
}
|
|
|
|
func FetchPublicURL(ctx context.Context, rawURL string, maxBytes int64) (*SafeFetchResult, error) {
|
|
if maxBytes <= 0 {
|
|
maxBytes = DefaultPluginMediaMaxBytes
|
|
}
|
|
parsed, err := url.Parse(rawURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return nil, fmt.Errorf("invalid public URL")
|
|
}
|
|
if parsed.User != nil {
|
|
return nil, fmt.Errorf("public URL must not include credentials")
|
|
}
|
|
config := safeurl.GetConfigBuilder().
|
|
SetTimeout(60*time.Second).
|
|
SetAllowedSchemes("http", "https").
|
|
SetAllowedPorts(80, 443).
|
|
EnableIPv6(true).
|
|
AllowSendingCredentials(false).
|
|
SetCheckRedirect(publicMediaRedirectPolicy).
|
|
Build()
|
|
client := safeurl.Client(config)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
body, err := ReadBoundedForPlugin(resp.Body, maxBytes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SafeFetchResult{
|
|
Body: body,
|
|
ContentType: resp.Header.Get("Content-Type"),
|
|
FinalURL: resp.Request.URL.String(),
|
|
}, nil
|
|
}
|
|
|
|
func publicMediaRedirectPolicy(req *http.Request, via []*http.Request) error {
|
|
if len(via) >= 10 {
|
|
return fmt.Errorf("too many redirects")
|
|
}
|
|
if req.URL.User != nil {
|
|
return fmt.Errorf("redirect URL must not include credentials")
|
|
}
|
|
if req.URL.Scheme != "http" && req.URL.Scheme != "https" {
|
|
return fmt.Errorf("redirect scheme must be http or https")
|
|
}
|
|
if len(via) > 0 && via[len(via)-1].URL.Scheme == "https" && req.URL.Scheme == "http" {
|
|
return fmt.Errorf("redirect downgrades https to http")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func ConnectorHTTPClient(policy ConnectorHTTPPolicy, checkRedirect func(req *http.Request, via []*http.Request) error) (*http.Client, error) {
|
|
base, err := url.Parse(policy.BaseURL)
|
|
if err != nil || base.Scheme == "" || base.Host == "" {
|
|
return nil, fmt.Errorf("invalid connector baseURL")
|
|
}
|
|
tlsConfig, err := connectorTLSConfig(policy.TLSMode, policy.TLSCABundle)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
dialer := &net.Dialer{Timeout: 30 * time.Second}
|
|
transport := &http.Transport{
|
|
TLSClientConfig: tlsConfig,
|
|
DialContext: func(ctx context.Context, network string, addr string) (net.Conn, error) {
|
|
host, port, err := net.SplitHostPort(addr)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
ips, err := net.DefaultResolver.LookupIP(ctx, "ip", host)
|
|
if err != nil || len(ips) == 0 {
|
|
return nil, fmt.Errorf("failed to resolve connector host: %w", err)
|
|
}
|
|
var selected net.IP
|
|
for _, ip := range ips {
|
|
if connectorIPAllowed(ip, policy.AllowPrivate) {
|
|
selected = ip
|
|
break
|
|
}
|
|
}
|
|
if selected == nil {
|
|
return nil, fmt.Errorf("connector host resolved outside allowed IP policy")
|
|
}
|
|
return dialer.DialContext(ctx, network, net.JoinHostPort(selected.String(), port))
|
|
},
|
|
}
|
|
return &http.Client{
|
|
Timeout: 60 * time.Second,
|
|
Transport: transport,
|
|
CheckRedirect: checkRedirect,
|
|
}, nil
|
|
}
|
|
|
|
func connectorTLSConfig(mode string, caBundle []byte) (*tls.Config, error) {
|
|
switch mode {
|
|
case "", "system":
|
|
return nil, nil
|
|
case "customCA":
|
|
roots, err := x509.SystemCertPool()
|
|
if err != nil || roots == nil {
|
|
roots = x509.NewCertPool()
|
|
}
|
|
if len(caBundle) == 0 || !roots.AppendCertsFromPEM(caBundle) {
|
|
return nil, fmt.Errorf("connector customCA bundle is invalid")
|
|
}
|
|
return &tls.Config{RootCAs: roots}, nil
|
|
default:
|
|
return nil, fmt.Errorf("unsupported connector TLS mode %q", mode)
|
|
}
|
|
}
|
|
|
|
func connectorIPAllowed(ip net.IP, allowPrivate bool) bool {
|
|
addr, ok := netip.AddrFromSlice(ip)
|
|
if !ok {
|
|
return false
|
|
}
|
|
if addr.Is4In6() {
|
|
addr = addr.Unmap()
|
|
}
|
|
if addr.IsLoopback() || addr.IsLinkLocalUnicast() || addr.IsLinkLocalMulticast() ||
|
|
addr.IsMulticast() || addr.IsUnspecified() {
|
|
return false
|
|
}
|
|
if isSpecialPurposeIP(addr) {
|
|
return false
|
|
}
|
|
if addr.IsPrivate() {
|
|
return allowPrivate
|
|
}
|
|
return true
|
|
}
|
|
|
|
func isSpecialPurposeIP(addr netip.Addr) bool {
|
|
for _, prefix := range specialPurposePrefixes {
|
|
if prefix.Contains(addr) {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
var specialPurposePrefixes = mustPrefixes(
|
|
"0.0.0.0/8",
|
|
"100.64.0.0/10",
|
|
"127.0.0.0/8",
|
|
"169.254.0.0/16",
|
|
"192.0.0.0/24",
|
|
"192.0.2.0/24",
|
|
"198.18.0.0/15",
|
|
"198.51.100.0/24",
|
|
"203.0.113.0/24",
|
|
"224.0.0.0/4",
|
|
"240.0.0.0/4",
|
|
"::/128",
|
|
"::1/128",
|
|
"64:ff9b::/96",
|
|
"100::/64",
|
|
"2001:db8::/32",
|
|
"fe80::/10",
|
|
"ff00::/8",
|
|
)
|
|
|
|
func mustPrefixes(values ...string) []netip.Prefix {
|
|
prefixes := make([]netip.Prefix, 0, len(values))
|
|
for _, value := range values {
|
|
prefix, err := netip.ParsePrefix(value)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
prefixes = append(prefixes, prefix)
|
|
}
|
|
return prefixes
|
|
}
|
|
|
|
func ReadBoundedForPlugin(reader io.Reader, maxBytes int64) ([]byte, error) {
|
|
body, err := io.ReadAll(io.LimitReader(reader, maxBytes+1))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if int64(len(body)) > maxBytes {
|
|
return nil, fmt.Errorf("response exceeds maximum size")
|
|
}
|
|
return body, nil
|
|
}
|