Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
257 changes: 257 additions & 0 deletions backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
package v1

import (
"context"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"regexp"
"strings"
"time"

"github.com/go-chi/chi/v5"
"github.com/hay-kot/httpkit/errchain"
"github.com/rs/zerolog/log"
"github.com/sysadminsmedia/homebox/backend/internal/core/services"
"github.com/sysadminsmedia/homebox/backend/internal/sys/validate"
"go.opentelemetry.io/otel/attribute"
)

// validIntegrationName restricts integration names to safe lower-case identifiers,
// preventing settings-key injection (e.g. "../../evil").
var validIntegrationName = regexp.MustCompile(`^[a-z][a-z0-9_-]{0,31}$`)

// blockedCIDRs lists address ranges the proxy must never reach.
// Prevents SSRF attacks against cloud metadata services (e.g. AWS IMDS at
// 169.254.169.254), loopback services, and internal infrastructure.
// Public hostnames are unrestricted; only private/reserved IPs are rejected.
var blockedCIDRs = func() []*net.IPNet {
blocks := []string{
"127.0.0.0/8", // IPv4 loopback
"::1/128", // IPv6 loopback
"169.254.0.0/16", // IPv4 link-local (AWS/GCP/Azure IMDS)
"fe80::/10", // IPv6 link-local
"10.0.0.0/8", // RFC1918
"172.16.0.0/12", // RFC1918
"192.168.0.0/16", // RFC1918
"0.0.0.0/8", // Unspecified
"::/128", // IPv6 unspecified
"100.64.0.0/10", // Shared address space (RFC6598)
"fc00::/7", // IPv6 unique-local (ULA)
}
nets := make([]*net.IPNet, 0, len(blocks))
for _, cidr := range blocks {
_, ipNet, _ := net.ParseCIDR(cidr)
nets = append(nets, ipNet)
}
return nets
}()

// checkBlockedIP returns an error if ip falls within any of the blocked ranges
// (loopback, link-local, RFC1918, cloud metadata, etc.).
func checkBlockedIP(ip net.IP) error {
for _, block := range blockedCIDRs {
if block.Contains(ip) {
return fmt.Errorf("integration proxy: address %s is in a blocked range", ip)
}
}
return nil
}

// ssrfSafeDialContext is a DialContext for proxyHTTPClient that rejects
// connections to private, loopback, link-local and other reserved ranges.
// Both literal-IP hosts and DNS-resolved hostnames are validated before dialing.
func ssrfSafeDialContext(ctx context.Context, network, addr string) (net.Conn, error) {
host, port, err := net.SplitHostPort(addr)
if err != nil {
return nil, fmt.Errorf("integration proxy: invalid address %q: %w", addr, err)
}
d := &net.Dialer{}
// Fast path: literal IP — validate directly, no DNS lookup or rebinding window.
if ip := net.ParseIP(host); ip != nil {
if err := checkBlockedIP(ip); err != nil {
return nil, err
}
return d.DialContext(ctx, network, net.JoinHostPort(ip.String(), port))
}
// Hostname: resolve all addresses and validate each before dialing.
ips, lookupErr := net.DefaultResolver.LookupIPAddr(ctx, host)
if lookupErr != nil {
return nil, fmt.Errorf("integration proxy: DNS lookup failed: %w", lookupErr)
}
if len(ips) == 0 {
return nil, fmt.Errorf("integration proxy: no addresses resolved for %q", host)
}
var lastErr error
for _, ia := range ips {
if err := checkBlockedIP(ia.IP); err != nil {
lastErr = err
continue
}
conn, dialErr := d.DialContext(ctx, network, net.JoinHostPort(ia.IP.String(), port))
if dialErr == nil {
return conn, nil
}
lastErr = dialErr
}
return nil, lastErr
}

// proxyHTTPClient is a shared client with a hard timeout and bounded pool.
// Using a dedicated client (not http.DefaultClient) prevents upstream services
// from hanging the server indefinitely.
var proxyHTTPClient = &http.Client{
Timeout: 30 * time.Second,
Transport: &http.Transport{
MaxIdleConns: 10,
IdleConnTimeout: 90 * time.Second,
DialContext: ssrfSafeDialContext,
},
}

// HandleIntegrationProxy godoc
//
// @Summary Integration Reverse Proxy
// @Description Proxies a single GET request to the configured external integration.
// The integration's credentials (base URL + API token) are read from
// user settings ({name}_url / {name}_token) and never exposed to the
// frontend. This single generic endpoint replaces all per-integration
// proxy handlers: adding a new integration only requires a Vue component
// and a settings entry — no new Go code.
// @Tags Integrations
// @Produce */*
// @Param name path string true "Integration name, e.g. paperless"
// @Param path query string true "Relative API path on the upstream service, must start with /"
// @Success 200
// @Failure 400 {object} validate.ErrorResponse
// @Failure 502 {object} validate.ErrorResponse
// @Router /v1/integrations/{name}/proxy [GET]
// @Security Bearer
func (ctrl *V1Controller) HandleIntegrationProxy() errchain.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) error {
spanCtx, span := startEntityCtrlSpan(r.Context(), "controller.V1.HandleIntegrationProxy")
defer span.End()

name := chi.URLParam(r, "name")
if !validIntegrationName.MatchString(name) {
return validate.NewRequestError(fmt.Errorf("invalid integration name"), http.StatusBadRequest)
}

rawPath := r.URL.Query().Get("path")
if rawPath == "" {
return validate.NewRequestError(fmt.Errorf("path query parameter is required"), http.StatusBadRequest)
}
if !strings.HasPrefix(rawPath, "/") || strings.Contains(rawPath, "://") {
return validate.NewRequestError(fmt.Errorf("path must be a relative path starting with /"), http.StatusBadRequest)
}

// Normalise to prevent directory traversal while preserving trailing slash
// (many REST APIs treat /foo/1/ and /foo/1 differently).
cleanPath := path.Clean(rawPath)
if !strings.HasPrefix(cleanPath, "/") {
return validate.NewRequestError(fmt.Errorf("invalid path after normalisation"), http.StatusBadRequest)
}
if strings.HasSuffix(rawPath, "/") && !strings.HasSuffix(cleanPath, "/") {
cleanPath += "/"
}

span.SetAttributes(
attribute.String("integration.name", name),
attribute.String("integration.path", cleanPath),
)

ctx := services.NewContext(spanCtx)
settings, svcErr := ctrl.svc.User.GetSettings(ctx.Context, services.UseUserCtx(ctx.Context).ID)
if svcErr != nil {
return validate.NewRequestError(svcErr, http.StatusInternalServerError)
}

baseURL, _ := settings[name+"_url"].(string)
if baseURL == "" {
return validate.NewRequestError(
fmt.Errorf("%s_url not configured – add it in Settings", name),
http.StatusBadRequest,
)
}
if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
return validate.NewRequestError(
fmt.Errorf("%s_url must use http:// or https:// scheme", name),
http.StatusBadRequest,
)
}

token, _ := settings[name+"_token"].(string)
if token == "" {
return validate.NewRequestError(
fmt.Errorf("%s_token not configured – add it in Settings", name),
http.StatusBadRequest,
)
}

upstream := strings.TrimRight(baseURL, "/") + cleanPath
Comment thread
coderabbitai[bot] marked this conversation as resolved.

req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, upstream, nil)
if err != nil {
return validate.NewRequestError(err, http.StatusBadRequest)
}
req.Header.Set("Authorization", "Token "+token)

resp, err := proxyHTTPClient.Do(req)
if err != nil {
// Log only host+path to avoid leaking query strings or embedded credentials.
var safeURL string
if u, parseErr := url.Parse(upstream); parseErr == nil {
safeURL = u.Host + u.Path
}
log.Err(err).Str("integration", name).Str("upstream", safeURL).Msg("integration proxy: upstream request failed")
return validate.NewRequestError(err, http.StatusBadGateway)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
defer func() { _ = resp.Body.Close() }()

if resp.StatusCode == http.StatusNotFound {
return validate.NewRequestError(fmt.Errorf("resource not found at upstream"), http.StatusNotFound)
}
if resp.StatusCode >= 400 {
return validate.NewRequestError(
fmt.Errorf("upstream returned %d", resp.StatusCode),
http.StatusBadGateway,
)
}

const maxResponseSize int64 = 10 * 1024 * 1024 // 10 MB

// Reject known-oversized responses before writing any bytes to the client.
if resp.ContentLength > maxResponseSize {
return validate.NewRequestError(
fmt.Errorf("upstream response too large (%d bytes)", resp.ContentLength),
http.StatusBadGateway,
)
}

// Buffer up to maxResponseSize+1 bytes so we can detect true truncation
// and return a clean 502 rather than a partial 200 with invalid JSON.
buf, readErr := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize+1))
if readErr != nil {
log.Err(readErr).Str("integration", name).Msg("integration proxy: failed to read response")
return validate.NewRequestError(fmt.Errorf("failed to read upstream response"), http.StatusBadGateway)
}
if int64(len(buf)) > maxResponseSize {
log.Warn().Str("integration", name).Msg("integration proxy: upstream response exceeded 10 MB limit")
return validate.NewRequestError(
fmt.Errorf("upstream response exceeds 10 MB limit"),
http.StatusBadGateway,
)
}

if ct := resp.Header.Get("Content-Type"); ct != "" {
w.Header().Set("Content-Type", ct)
}
if _, writeErr := w.Write(buf); writeErr != nil {
log.Err(writeErr).Str("integration", name).Msg("integration proxy: failed to write response")
}
return nil
}
}
3 changes: 3 additions & 0 deletions backend/app/api/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,9 @@ func (a *app) mountRoutes(r *chi.Mux, chain *errchain.ErrChain, repos *repo.AllR
r.Delete("/notifiers/{id}", chain.ToHandlerFunc(v1Ctrl.HandleDeleteNotifier(), userMW...))
r.Post("/notifiers/test", chain.ToHandlerFunc(v1Ctrl.HandlerNotifierTest(), append(userMW, a.notifierTestLimiter.middleware)...))

// Integration proxy endpoints
r.Get("/integrations/{name}/proxy", chain.ToHandlerFunc(v1Ctrl.HandleIntegrationProxy(), userMW...))

// Asset-Like endpoints
assetMW := []errchain.Middleware{
a.mwAuthToken,
Expand Down
18 changes: 0 additions & 18 deletions backend/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -141,12 +141,6 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/clipperhouse/displaywidth v0.6.2 h1:ZDpTkFfpHOKte4RG5O/BOyf3ysnvFswpyYrV7z2uAKo=
github.com/clipperhouse/displaywidth v0.6.2/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o=
github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4=
github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w=
github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI=
github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
Expand Down Expand Up @@ -384,14 +378,6 @@ github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOF
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olahol/melody v1.4.0 h1:Pa5SdeZL/zXPi1tJuMAPDbl4n3gQOThSL6G1p4qZ4SI=
github.com/olahol/melody v1.4.0/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6 h1:zrbMGy9YXpIeTnGj4EljqMiZsIcE09mmF8XsD5AYOJc=
github.com/olekukonko/cat v0.0.0-20250911104152-50322a0618f6/go.mod h1:rEKTHC9roVVicUIfZK7DYrdIoM0EOr8mK1Hj5s3JjH0=
github.com/olekukonko/errors v1.1.0 h1:RNuGIh15QdDenh+hNvKrJkmxxjV4hcS50Db478Ou5sM=
github.com/olekukonko/errors v1.1.0/go.mod h1:ppzxA5jBKcO1vIpCXQ9ZqgDh8iwODz6OXIGKU8r5m4Y=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0 h1:jrYnow5+hy3WRDCBypUFvVKNSPPCdqgSXIE9eJDD8LM=
github.com/olekukonko/ll v0.1.4-0.20260115111900-9e59c2286df0/go.mod h1:b52bVQRRPObe+yyBl0TxNfhesL0nedD4Cht0/zx55Ew=
github.com/olekukonko/tablewriter v1.1.3 h1:VSHhghXxrP0JHl+0NnKid7WoEmd9/urKRJLysb70nnA=
github.com/olekukonko/tablewriter v1.1.3/go.mod h1:9VU0knjhmMkXjnMKrZ3+L2JhhtsQ/L38BbL3CRNE8tM=
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
Expand Down Expand Up @@ -439,10 +425,6 @@ github.com/shirou/gopsutil/v4 v4.26.4 h1:B4SXVbcwTyrocPHEmWBC4uCYr4Xcu3MK1TXqbpr
github.com/shirou/gopsutil/v4 v4.26.4/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spiffe/go-spiffe/v2 v2.6.0 h1:l+DolpxNWYgruGQVV0xsfeya3CsC7m8iBzDnMpsbLuo=
github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
Expand Down
Loading
Loading