-
-
Notifications
You must be signed in to change notification settings - Fork 443
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
base: main
Are you sure you want to change the base?
Changes from all commits
df2acff
2ea776d
1562632
3617164
0a7ce56
0bce93f
31a387e
07641a5
d9a7bed
630bbd9
73257bb
e991bfc
3762dc9
1186d27
f678969
7735eeb
42ffe59
89607b6
fdbae8c
763fbaf
a7a918e
3fe43cc
2d96300
ccec3d8
51a4f99
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,181 @@ | ||
| package v1 | ||
|
|
||
| import ( | ||
| "fmt" | ||
| "io" | ||
| "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}$`) | ||
|
|
||
| func (ctrl *V1Controller) integrationProxyHTTPClient() *http.Client { | ||
| transport := ctrl.integrationProxyTransport | ||
| if transport == nil { | ||
| transport = validate.NewOutboundHTTPTransport(&ctrl.config.Notifier) | ||
| } | ||
|
|
||
| return &http.Client{ | ||
| Timeout: 30 * time.Second, | ||
| Transport: transport, | ||
| CheckRedirect: func(req *http.Request, _ []*http.Request) error { | ||
| if err := validate.ValidateOutboundHTTPURLWithContext(req.Context(), req.URL.String(), &ctrl.config.Notifier); err != nil { | ||
| return fmt.Errorf("integration proxy redirect blocked: %w", err) | ||
| } | ||
| return nil | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // HandleIntegrationProxy godoc | ||
| // | ||
| // @Summary Integration Reverse Proxy | ||
| // @Description Proxies a single GET request to a 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. The current frontend uses this for Paperless-style token | ||
| // authentication. | ||
| // @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, | ||
| ) | ||
| } | ||
|
|
||
| upstream := strings.TrimRight(baseURL, "/") + cleanPath | ||
| if err := validate.ValidateOutboundHTTPURLWithContext(r.Context(), upstream, &ctrl.config.Notifier); err != nil { | ||
| return validate.NewRequestError(err, 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, | ||
| ) | ||
| } | ||
|
|
||
| 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 := ctrl.integrationProxyHTTPClient().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) | ||
| } | ||
| 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 | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package v1 | ||
|
|
||
| import ( | ||
| "net/http" | ||
| "testing" | ||
|
|
||
| "github.com/sysadminsmedia/homebox/backend/internal/sys/config" | ||
| ) | ||
|
|
||
| func TestIntegrationProxyHTTPClientValidatesRedirectTargets(t *testing.T) { | ||
| ctrl := &V1Controller{ | ||
| config: &config.Config{ | ||
| Notifier: config.NotifierConf{ | ||
| BlockCloudMetadata: true, | ||
| Dns64Nets: []string{"64:ff9b::/96", "64:ff9b:1::/48"}, | ||
| }, | ||
| }, | ||
| } | ||
|
|
||
| client := ctrl.integrationProxyHTTPClient() | ||
| req, err := http.NewRequest(http.MethodGet, "http://169.254.169.254/latest/meta-data", nil) | ||
| if err != nil { | ||
| t.Fatalf("failed to build redirect request: %v", err) | ||
| } | ||
|
|
||
| if err := client.CheckRedirect(req, nil); err == nil { | ||
| t.Fatal("expected redirect to cloud metadata endpoint to be blocked") | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,8 +2,12 @@ package services | |
|
|
||
| import ( | ||
| "context" | ||
| "crypto/sha256" | ||
| "encoding/hex" | ||
| "fmt" | ||
| "io" | ||
| "net/url" | ||
| "strings" | ||
|
|
||
| "github.com/google/uuid" | ||
| "github.com/rs/zerolog/log" | ||
|
|
@@ -14,6 +18,23 @@ import ( | |
| "go.opentelemetry.io/otel/trace" | ||
| ) | ||
|
|
||
| func redactExternalIdentifierForTrace(sourceType, externalID string) string { | ||
| if sourceType != "link" { | ||
| return externalID | ||
| } | ||
|
|
||
| u, err := url.Parse(strings.TrimSpace(externalID)) | ||
| if err != nil { | ||
| return "" | ||
| } | ||
| u.User = nil | ||
| u.RawQuery = "" | ||
| u.Fragment = "" | ||
|
|
||
| pathHash := sha256.Sum256([]byte(u.EscapedPath())) | ||
| return fmt.Sprintf("%s://%s/path:%s", u.Scheme, u.Host, hex.EncodeToString(pathHash[:8])) | ||
| } | ||
|
Comment on lines
+21
to
+36
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win Redact the hostname too before tracing For 🔐 Proposed fix func redactExternalIdentifierForTrace(sourceType, externalID string) string {
if sourceType != "link" {
return externalID
}
u, err := url.Parse(strings.TrimSpace(externalID))
if err != nil {
return ""
}
u.User = nil
u.RawQuery = ""
u.Fragment = ""
+ hostHash := sha256.Sum256([]byte(strings.ToLower(u.Host)))
pathHash := sha256.Sum256([]byte(u.EscapedPath()))
- return fmt.Sprintf("%s://%s/path:%s", u.Scheme, u.Host, hex.EncodeToString(pathHash[:8]))
+ return fmt.Sprintf(
+ "%s://host:%s/path:%s",
+ u.Scheme,
+ hex.EncodeToString(hostHash[:8]),
+ hex.EncodeToString(pathHash[:8]),
+ )
}Also applies to: 144-144 🤖 Prompt for AI Agents |
||
|
|
||
| func (svc *EntityService) AttachmentPath(ctx context.Context, gid uuid.UUID, attachmentID uuid.UUID) (*ent.Attachment, error) { | ||
| ctx, span := entityServiceTracer().Start(ctx, "service.EntityService.AttachmentPath", | ||
| trace.WithAttributes( | ||
|
|
@@ -120,7 +141,7 @@ func (svc *EntityService) AttachmentAddExternalLink(ctx Context, entityID uuid.U | |
| attribute.String("group.id", ctx.GID.String()), | ||
| attribute.String("entity.id", entityID.String()), | ||
| attribute.String("integration.source_type", sourceType), | ||
| attribute.String("integration.external_id", externalID), | ||
| attribute.String("integration.external_id", redactExternalIdentifierForTrace(sourceType, externalID)), | ||
| )) | ||
| defer span.End() | ||
| ctx.Context = spanCtx | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: sysadminsmedia/homebox
Length of output: 13671
🏁 Script executed:
Repository: sysadminsmedia/homebox
Length of output: 40809
🏁 Script executed:
Repository: sysadminsmedia/homebox
Length of output: 165
🏁 Script executed:
Repository: sysadminsmedia/homebox
Length of output: 5797
🏁 Script executed:
Repository: sysadminsmedia/homebox
Length of output: 1350
Gate
nameto supported integrations.HandleUserSelfSettingsUpdatepersists arbitrary settings keys, so regex-only validation still allows any lower-case{name}to become an authenticated outbound proxy via{name}_url/{name}_token.🤖 Prompt for AI Agents