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
29 changes: 29 additions & 0 deletions services/proxy/pkg/command/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ package command
import (
"context"
"crypto/tls"
"crypto/x509"
"fmt"
"net/http"
"os"
"os/signal"
"time"

Expand Down Expand Up @@ -276,6 +278,28 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config,
Timeout: time.Second * 10,
}

backendTLSConfig := &tls.Config{
MinVersion: tls.VersionTLS12,
InsecureSkipVerify: cfg.InsecureBackends, //nolint:gosec
}
if cfg.BackendHTTPSCACert != "" {
certs := x509.NewCertPool()
pemData, err := os.ReadFile(cfg.BackendHTTPSCACert)
Comment thread
Guibi1 marked this conversation as resolved.
if err != nil {
logger.Fatal().Err(err).Msg("Failed to read backend HTTPS CA certificate")
}
if !certs.AppendCertsFromPEM(pemData) {
logger.Fatal().Msg("Failed to append backend HTTPS CA certificate")
}
backendTLSConfig.RootCAs = certs
}
backendHTTPClient := &http.Client{
Transport: &http.Transport{
TLSClientConfig: backendTLSConfig,
},
Timeout: time.Second * 10,
Comment on lines +296 to +300
}
Comment thread
Guibi1 marked this conversation as resolved.

var authenticators []middleware.Authenticator
if cfg.EnableBasicAuth {
logger.Warn().Msg("basic auth enabled, use only for testing or development")
Expand Down Expand Up @@ -363,10 +387,15 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config,
middleware.TraceProvider(traceProvider),
middleware.UserProvider(userProvider),
middleware.UserRoleAssigner(roleAssigner),
middleware.HTTPClient(oidcHTTPClient),
middleware.BackendHTTPClient(backendHTTPClient),
middleware.OIDCIss(cfg.OIDC.Issuer),
middleware.ServiceSelector(serviceSelector),
middleware.SkipUserInfo(cfg.OIDC.SkipUserInfo),
middleware.UserOIDCClaim(cfg.UserOIDCClaim),
middleware.UserCS3Claim(cfg.UserCS3Claim),
middleware.TenantOIDCClaim(cfg.TenantOIDCClaim),
middleware.AutoProvisionClaims(cfg.AutoProvisionClaims),
middleware.TenantIDMappingEnabled(cfg.TenantIDMappingEnabled),
middleware.ServiceAccount(cfg.ServiceAccount),
middleware.WithRevaGatewaySelector(gatewaySelector),
Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ type AutoProvisionClaims struct {
Username string `yaml:"username" env:"PROXY_AUTOPROVISION_CLAIM_USERNAME" desc:"The name of the OIDC claim that holds the username." introductionVersion:"1.0.0"`
Email string `yaml:"email" env:"PROXY_AUTOPROVISION_CLAIM_EMAIL" desc:"The name of the OIDC claim that holds the email." introductionVersion:"1.0.0"`
DisplayName string `yaml:"display_name" env:"PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME" desc:"The name of the OIDC claim that holds the display name." introductionVersion:"1.0.0"`
ProfilePicture string `yaml:"profile_picture" env:"PROXY_AUTOPROVISION_CLAIM_PROFILE_PICTURE" desc:"The name of the OIDC claim that holds the profile picture URL. When set, the profile picture will be synced on login."`
Comment thread
Guibi1 marked this conversation as resolved.
Comment thread
Guibi1 marked this conversation as resolved.
Groups string `yaml:"groups" env:"PROXY_AUTOPROVISION_CLAIM_GROUPS" desc:"The name of the OIDC claim that holds the groups." introductionVersion:"1.0.0"`
Comment thread
Guibi1 marked this conversation as resolved.
}

Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func DefaultConfig() *config.Config {
Username: "preferred_username",
Email: "email",
DisplayName: "name",
ProfilePicture: "",
Groups: "groups",
},
EnableBasicAuth: false,
Expand Down
246 changes: 217 additions & 29 deletions services/proxy/pkg/middleware/account_resolver.go
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
package middleware

import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
Expand All @@ -14,6 +18,7 @@ import (
"github.com/opencloud-eu/opencloud/services/proxy/pkg/router"
"github.com/opencloud-eu/opencloud/services/proxy/pkg/user/backend"
"github.com/opencloud-eu/opencloud/services/proxy/pkg/userroles"
"go-micro.dev/v4/selector"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"

Expand All @@ -27,6 +32,11 @@ import (
"github.com/opencloud-eu/reva/v2/pkg/utils"
)

const (
graphServiceName = "eu.opencloud.web.graph"
Comment thread
Guibi1 marked this conversation as resolved.
maxProfilePhotoBytes = 10 << 20
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Suggestion: The 10MB limit for profile photos is excessive for a synchronous automated sync process. Consider reducing this to 1-2MB to prevent high memory usage and mitigate resource exhaustion risks during peak login times.

)

// AccountResolver provides a middleware which mints a jwt and adds it to the proxied request based
// on the oidc-claims
func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handler {
Expand All @@ -46,42 +56,61 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl
)
go tenantIDCache.Start()

httpClient := options.HTTPClient
if httpClient == nil {
httpClient = &http.Client{Timeout: 10 * time.Second}
}
backendHTTPClient := options.BackendHTTPClient
if backendHTTPClient == nil {
backendHTTPClient = &http.Client{Timeout: 10 * time.Second}
}

return func(next http.Handler) http.Handler {
return &accountResolver{
next: next,
logger: logger,
tracer: tracer,
userProvider: options.UserProvider,
userOIDCClaim: options.UserOIDCClaim,
userCS3Claim: options.UserCS3Claim,
tenantOIDCClaim: options.TenantOIDCClaim,
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
gatewaySelector: options.RevaGatewaySelector,
serviceAccount: options.ServiceAccount,
userRoleAssigner: options.UserRoleAssigner,
autoProvisionAccounts: options.AutoprovisionAccounts,
multiTenantEnabled: options.MultiTenantEnabled,
lastGroupSyncCache: lastGroupSyncCache,
tenantIDCache: tenantIDCache,
eventsPublisher: options.EventsPublisher,
next: next,
logger: logger,
tracer: tracer,
userProvider: options.UserProvider,
userOIDCClaim: options.UserOIDCClaim,
userCS3Claim: options.UserCS3Claim,
tenantOIDCClaim: options.TenantOIDCClaim,
autoProvisionClaims: options.AutoProvisionClaims,
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
gatewaySelector: options.RevaGatewaySelector,
serviceAccount: options.ServiceAccount,
userRoleAssigner: options.UserRoleAssigner,
autoProvisionAccounts: options.AutoprovisionAccounts,
multiTenantEnabled: options.MultiTenantEnabled,
lastGroupSyncCache: lastGroupSyncCache,
tenantIDCache: tenantIDCache,
eventsPublisher: options.EventsPublisher,
httpClient: httpClient,
backendHTTPClient: backendHTTPClient,
oidcIssuer: options.OIDCIss,
serviceSelector: options.ServiceSelector,
Comment thread
Guibi1 marked this conversation as resolved.
}
}
}

type accountResolver struct {
next http.Handler
logger log.Logger
tracer trace.Tracer
userProvider backend.UserBackend
userRoleAssigner userroles.UserRoleAssigner
autoProvisionAccounts bool
multiTenantEnabled bool
tenantIDMappingEnabled bool
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
serviceAccount config.ServiceAccount
userOIDCClaim string
userCS3Claim string
tenantOIDCClaim string
next http.Handler
logger log.Logger
tracer trace.Tracer
userProvider backend.UserBackend
userRoleAssigner userroles.UserRoleAssigner
autoProvisionAccounts bool
multiTenantEnabled bool
tenantIDMappingEnabled bool
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
serviceSelector selector.Selector
serviceAccount config.ServiceAccount
userOIDCClaim string
userCS3Claim string
tenantOIDCClaim string
autoProvisionClaims config.AutoProvisionClaims
oidcIssuer string
httpClient *http.Client
backendHTTPClient *http.Client
// lastGroupSyncCache is used to keep track of when the last sync of group
// memberships was done for a specific user. This is used to trigger a sync
// with every single request.
Expand Down Expand Up @@ -126,6 +155,159 @@ func readStringClaim(path string, claims map[string]any) (string, error) {
return value, fmt.Errorf("claim path '%s' not set or empty", path)
}

func (m accountResolver) syncProfilePicture(ctx context.Context, req *http.Request, user *cs3user.User, token string, claims map[string]any) error {
if user == nil {
return errors.New("missing user for profile photo sync")
}
if token == "" {
return errors.New("missing user token for profile photo sync")
}

pictureURL, err := readStringClaim(m.autoProvisionClaims.ProfilePicture, claims)
if err != nil {
m.logger.Debug().Err(err).Str("claim", m.autoProvisionClaims.ProfilePicture).Msg("profile picture claim missing")
return nil
}
if pictureURL == "" {
return nil
}

parsedURL, err := url.Parse(pictureURL)
if err != nil {
return fmt.Errorf("invalid profile picture URL: %w", err)
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("unsupported profile picture URL scheme: %s", parsedURL.Scheme)
}
if parsedURL.Host == "" {
return fmt.Errorf("profile picture URL is missing a host")
}
Comment on lines +175 to +184

authHeader := ""
if req != nil {
authHeader = req.Header.Get("Authorization")
}

photo, err := m.fetchProfilePicture(ctx, parsedURL, authHeader)
Comment thread
Guibi1 marked this conversation as resolved.
if err != nil {
return err
}

return m.updateGraphProfilePhoto(ctx, token, photo)
}

func (m accountResolver) fetchProfilePicture(ctx context.Context, pictureURL *url.URL, authHeader string) ([]byte, error) {
client := m.httpClient
if client == nil {
client = http.DefaultClient
}

request, err := http.NewRequestWithContext(ctx, http.MethodGet, pictureURL.String(), nil)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 HIGH RISK

Fetching the profile picture from an arbitrary URL provided in OIDC claims poses a SSRF risk. You should validate that the URL's host matches the OIDC issuer or an allowed list of trusted domains to prevent internal network scanning.

if err != nil {
return nil, err
}
request.Header.Set("Accept", "image/*")
if authHeader != "" && m.shouldAttachOIDCToken(pictureURL) {
request.Header.Set("Authorization", authHeader)
}

resp, err := client.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("profile picture request returned %s", resp.Status)
}

limited := io.LimitReader(resp.Body, int64(maxProfilePhotoBytes)+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, errors.New("profile picture response was empty")
}
if len(data) > maxProfilePhotoBytes {
return nil, fmt.Errorf("profile picture exceeds %d bytes", maxProfilePhotoBytes)
}
contentType := http.DetectContentType(data)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚪ LOW RISK

Suggestion: The content-type is detected twice for the same image data. Optimize this by returning the detected content-type from fetchProfilePicture and passing it to updateGraphProfilePhoto.

if !strings.HasPrefix(contentType, "image/") {
return nil, fmt.Errorf("unsupported profile picture content type: %s", contentType)
}

return data, nil
}

func (m accountResolver) shouldAttachOIDCToken(pictureURL *url.URL) bool {
if m.oidcIssuer == "" || pictureURL == nil {
return false
}
issuerURL, err := url.Parse(m.oidcIssuer)
if err != nil || issuerURL.Host == "" {
return false
}
return strings.EqualFold(issuerURL.Host, pictureURL.Host)
Comment thread
Guibi1 marked this conversation as resolved.
Comment on lines +247 to +251
}

func (m accountResolver) updateGraphProfilePhoto(ctx context.Context, token string, photo []byte) error {
if token == "" {
return errors.New("missing access token for graph profile photo update")
}
baseURL, err := m.graphBaseURL()
if err != nil {
return err
}

endpoint := baseURL + "/v1.0/me/photo/$value"
request, err := http.NewRequestWithContext(ctx, http.MethodPut, endpoint, bytes.NewReader(photo))
if err != nil {
return err
}
request.Header.Set(revactx.TokenHeader, token)
request.Header.Set("Content-Type", http.DetectContentType(photo))

client := m.backendHTTPClient
if client == nil {
client = http.DefaultClient
}
resp, err := client.Do(request)
if err != nil {
return err
}
defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
body, _ := io.ReadAll(io.LimitReader(resp.Body, 2048))
return fmt.Errorf("graph profile photo update failed: %s (%s)", resp.Status, strings.TrimSpace(string(body)))
}

return nil
}

func (m accountResolver) graphBaseURL() (string, error) {
if m.serviceSelector == nil {
return "", errors.New("service selector not configured")
}
selectNext, err := m.serviceSelector.Select(graphServiceName)
if err != nil {
return "", err
}
node, err := selectNext()
if err != nil {
return "", err
}
scheme := node.Metadata["protocol"]
if node.Metadata["use_tls"] == "true" {
scheme = "https"
}
if scheme == "" {
scheme = "http"
}
return fmt.Sprintf("%s://%s/graph", scheme, node.Address), nil
}

// TODO do not use the context to store values: https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
Expand Down Expand Up @@ -219,6 +401,12 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
}
}

if m.autoProvisionClaims.ProfilePicture != "" && oidc.NewSessionFlagFromContext(ctx) {
if err := m.syncProfilePicture(ctx, req, user, token, claims); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 MEDIUM RISK

Suggestion: Syncing the profile picture synchronously blocks the request flow and increases latency for the initial session request. Consider running this in a background goroutine using a detached context (e.g., context.WithoutCancel).

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This might be a good idea.

m.logger.Warn().Err(err).Str("userid", user.GetId().GetOpaqueId()).Msg("Failed to sync profile picture from OIDC claim")
}
}

// resolve the user's roles
user, err = m.userRoleAssigner.UpdateUserRoleAssignment(ctx, user, claims)
if err != nil {
Expand Down
Loading