-
-
Notifications
You must be signed in to change notification settings - Fork 409
Feat/paperless attachement links #1492
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
szaiser
wants to merge
12
commits into
sysadminsmedia:main
Choose a base branch
from
szaiser:feat/paperless-immich-attachement-links
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
91394d9
feat(integrations): add Paperless-ngx and Immich as external attachme…
750e274
feat(integrations): Paperless & Immich attachment integration
ab4c1b6
refactor(integrations): remove Immich, keep Paperless-only adapter re…
e40a612
fix(integrations): address CodeRabbit review issues on PR #1492
f2f51c6
refactor(integrations): fully remove Immich from backend
ce9f33d
remove todo file
b7f8f06
refactor(tests): remove immich from use-preferences test fixtures
300c41e
fix(proxy): harden integration proxy against SSRF and edge cases
be4271d
docs(proxy): add missing docstrings to reach coverage threshold
dbed0f7
fix(proxy): bind request to handler context; redact URL in logs
7c88c59
refactor: implement code review findings across all PR files
30e18e2
refactor: drop externalLinkMimeTypes IIFE, range map directly in isEx…
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
257 changes: 257 additions & 0 deletions
257
backend/app/api/handlers/v1/v1_ctrl_integration_proxy.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| 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) | ||
|
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 | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.