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
7 changes: 3 additions & 4 deletions cmd/serve.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"
Expand All @@ -29,8 +30,6 @@ import (
"github.com/raystack/frontier/core/prospect"
"github.com/raystack/frontier/core/userpat"

"slices"

"github.com/doug-martin/goqu/v9"
"github.com/jackc/pgx/v4"
"github.com/stripe/stripe-go/v79"
Expand Down Expand Up @@ -433,13 +432,13 @@ func buildAPIDependencies(
// back here because role.Service depends on permission.Service
permissionService.SetRoleService(roleService)
policyService := policy.NewService(policyPGRepository, relationService, roleService)
userService := user.NewService(userRepository, relationService, sessionService, auditRecordRepository)
userService := user.NewService(userRepository, relationService, sessionService, auditRecordRepository, cfg.App.Avatar)
patValidator := userpat.NewValidator(logger, userPATRepo, cfg.App.PAT)
authnService := authenticate.NewService(logger, cfg.App.Authentication,
postgres.NewFlowRepository(logger, dbc), mailDialer, tokenService, sessionService, userService, serviceUserService, webAuthConfig, patValidator)
groupService := group.NewService(groupRepository, relationService, authnService, policyService)
organizationService := organization.NewService(organizationRepository, relationService, userService,
authnService, policyService, preferenceService, roleService)
authnService, policyService, preferenceService, roleService, cfg.App.Avatar)
projectRepository := postgres.NewProjectRepository(dbc)
projectService := project.NewService(projectRepository, relationService, userService, policyService,
authnService, serviceUserService, groupService, roleService)
Expand Down
78 changes: 78 additions & 0 deletions core/avatar/avatar.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package avatar

import (
"bytes"
"encoding/base64"
"errors"
"strings"
)

// DefaultMaxBytes caps the stored avatar string at 1 MiB when no limit is
// configured. The limit is measured on the encoded data URL string.
const DefaultMaxBytes = 1 << 20

// Config holds avatar validation settings.
type Config struct {
// MaxSizeBytes is the largest allowed size of a stored avatar string. An
// avatar is a base64 data URL, so this is measured on the encoded string.
MaxSizeBytes int `yaml:"max_size_bytes" mapstructure:"max_size_bytes" default:"1048576"`
}

const (
jpegDataURLPrefix = "data:image/jpeg;base64,"
pngDataURLPrefix = "data:image/png;base64,"
)

// ErrInvalid is returned when an avatar is not an allowed image data URL.
var ErrInvalid = errors.New("avatar must be a base64 JPEG or PNG data URL")

var (
jpegMagic = []byte{0xFF, 0xD8, 0xFF}
pngMagic = []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}
)

// Validate checks that an avatar is either empty or a base64 JPEG/PNG data
// URL within the size limit.
func Validate(value string, cfg Config) error {
if value == "" {
return nil
}

maxBytes := cfg.MaxSizeBytes
if maxBytes <= 0 {
maxBytes = DefaultMaxBytes
}

// Check the length before decoding so oversized input is rejected cheaply.
if len(value) > maxBytes {
return ErrInvalid
}

payload, magic, ok := splitDataURL(value)
if !ok {
return ErrInvalid
}

raw, err := base64.StdEncoding.DecodeString(payload)
if err != nil {
return ErrInvalid
}

if !bytes.HasPrefix(raw, magic) {
return ErrInvalid
}
return nil
}

// splitDataURL returns the base64 payload and the file signature the decoded
// bytes must start with. ok is false for anything but a JPEG or PNG data URL.
func splitDataURL(value string) (payload string, magic []byte, ok bool) {
switch {
case strings.HasPrefix(value, jpegDataURLPrefix):
return value[len(jpegDataURLPrefix):], jpegMagic, true
case strings.HasPrefix(value, pngDataURLPrefix):
return value[len(pngDataURLPrefix):], pngMagic, true
default:
return "", nil, false
}
}
50 changes: 50 additions & 0 deletions core/avatar/avatar_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package avatar

import (
"encoding/base64"
"strings"
"testing"

"github.com/stretchr/testify/assert"
)

func TestValidate(t *testing.T) {
jpeg := jpegDataURLPrefix + base64.StdEncoding.EncodeToString([]byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10})
png := pngDataURLPrefix + base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
pngBytesAsJPEG := jpegDataURLPrefix + base64.StdEncoding.EncodeToString([]byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A})
notAnImage := jpegDataURLPrefix + base64.StdEncoding.EncodeToString([]byte("hello there"))
oversized := jpegDataURLPrefix + strings.Repeat("A", DefaultMaxBytes)

tests := []struct {
name string
value string
cfg Config
wantErr bool
}{
{name: "empty is allowed", value: "", wantErr: false},
{name: "valid jpeg base64", value: jpeg, wantErr: false},
{name: "valid png base64", value: png, wantErr: false},
{name: "zero limit falls back to default", value: jpeg, cfg: Config{MaxSizeBytes: 0}, wantErr: false},
{name: "valid jpeg rejected under tiny limit", value: jpeg, cfg: Config{MaxSizeBytes: 8}, wantErr: true},
{name: "rejects svg data url", value: "data:image/svg+xml;base64,PHN2ZyBvbmxvYWQ9ImFsZXJ0KDEpIi8+", wantErr: true},
{name: "rejects javascript scheme", value: "javascript:alert(document.cookie)", wantErr: true},
{name: "rejects http url", value: "http://169.254.169.254/latest/meta-data/", wantErr: true},
{name: "rejects https url", value: "https://attacker.example/collect?u=admin", wantErr: true},
{name: "rejects oversized payload", value: oversized, wantErr: true},
{name: "rejects invalid base64", value: jpegDataURLPrefix + "@@@not-base64@@@", wantErr: true},
{name: "rejects png bytes declared as jpeg", value: pngBytesAsJPEG, wantErr: true},
{name: "rejects non-image bytes", value: notAnImage, wantErr: true},
{name: "rejects data url without base64", value: "data:image/jpeg,rawdata", wantErr: true},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := Validate(tt.value, tt.cfg)
if tt.wantErr {
assert.ErrorIs(t, err, ErrInvalid)
} else {
assert.NoError(t, err)
}
})
}
}
14 changes: 13 additions & 1 deletion core/organization/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import (
"github.com/raystack/frontier/core/role"

"github.com/raystack/frontier/core/authenticate"
"github.com/raystack/frontier/core/avatar"

"github.com/raystack/frontier/pkg/str"
"github.com/raystack/frontier/pkg/utils"
Expand Down Expand Up @@ -79,11 +80,12 @@ type Service struct {
prefService PreferencesService
roleService RoleService
membershipService MembershipService
avatarConfig avatar.Config
}

func NewService(repository Repository, relationService RelationService,
userService UserService, authnService AuthnService, policyService PolicyService,
prefService PreferencesService, roleService RoleService) *Service {
prefService PreferencesService, roleService RoleService, avatarConfig avatar.Config) *Service {
return &Service{
repository: repository,
relationService: relationService,
Expand All @@ -92,6 +94,7 @@ func NewService(repository Repository, relationService RelationService,
policyService: policyService,
prefService: prefService,
roleService: roleService,
avatarConfig: avatarConfig,
}
}

Expand Down Expand Up @@ -149,6 +152,9 @@ func (s Service) GetDefaultOrgStateOnCreate(ctx context.Context) (State, error)
}

func (s Service) Create(ctx context.Context, org Organization) (Organization, error) {
if err := avatar.Validate(org.Avatar, s.avatarConfig); err != nil {
return Organization{}, err
}
principal, err := s.authnService.GetPrincipal(ctx)
if err != nil {
return Organization{}, fmt.Errorf("%w: %s", user.ErrNotExist, err.Error())
Expand Down Expand Up @@ -239,6 +245,9 @@ func (s Service) List(ctx context.Context, f Filter) ([]Organization, error) {
}

func (s Service) Update(ctx context.Context, org Organization) (Organization, error) {
if err := avatar.Validate(org.Avatar, s.avatarConfig); err != nil {
return Organization{}, err
}
if org.ID != "" {
return s.repository.UpdateByID(ctx, org)
}
Expand Down Expand Up @@ -281,6 +290,9 @@ func (s Service) MemberCount(ctx context.Context, orgID string) (int64, error) {
}

func (s Service) AdminCreate(ctx context.Context, org Organization, ownerEmail string) (Organization, error) {
if err := avatar.Validate(org.Avatar, s.avatarConfig); err != nil {
return Organization{}, err
}
// Validate email
if !user.IsValidEmail(ownerEmail) {
return Organization{}, user.ErrInvalidEmail
Expand Down
49 changes: 44 additions & 5 deletions core/organization/service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"testing"

"github.com/google/uuid"
"github.com/raystack/frontier/core/avatar"
"github.com/raystack/frontier/core/organization"
"github.com/raystack/frontier/core/organization/mocks"
"github.com/raystack/frontier/core/preference"
Expand All @@ -15,6 +16,44 @@ import (
"github.com/stretchr/testify/mock"
)

func TestService_CreateRejectsInvalidAvatar(t *testing.T) {
svc := organization.NewService(
mocks.NewRepository(t),
mocks.NewRelationService(t),
mocks.NewUserService(t),
mocks.NewAuthnService(t),
mocks.NewPolicyService(t),
mocks.NewPreferencesService(t),
mocks.NewRoleService(t),
avatar.Config{},
)

_, err := svc.Create(context.Background(), organization.Organization{
Name: "acme",
Avatar: "data:image/svg+xml;base64,PHN2Zy8+",
})
assert.ErrorIs(t, err, avatar.ErrInvalid)
}

func TestService_UpdateRejectsInvalidAvatar(t *testing.T) {
svc := organization.NewService(
mocks.NewRepository(t),
mocks.NewRelationService(t),
mocks.NewUserService(t),
mocks.NewAuthnService(t),
mocks.NewPolicyService(t),
mocks.NewPreferencesService(t),
mocks.NewRoleService(t),
avatar.Config{},
)

_, err := svc.Update(context.Background(), organization.Organization{
ID: uuid.New().String(),
Avatar: "http://169.254.169.254/latest/meta-data/",
})
assert.ErrorIs(t, err, avatar.ErrInvalid)
}

func TestService_Get(t *testing.T) {
mockRepo := mocks.NewRepository(t)
mockRelationSvc := mocks.NewRelationService(t)
Expand All @@ -24,7 +63,7 @@ func TestService_Get(t *testing.T) {
mockPrefSvc := mocks.NewPreferencesService(t)

mockRoleSvc := mocks.NewRoleService(t)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc, avatar.Config{})

t.Run("should return orgs when fetched by id (by calling repo.GetByID)", func(t *testing.T) {
IDParam := uuid.New()
Expand Down Expand Up @@ -82,7 +121,7 @@ func TestService_GetRaw(t *testing.T) {
mockPrefSvc := mocks.NewPreferencesService(t)

mockRoleSvc := mocks.NewRoleService(t)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc, avatar.Config{})

t.Run("should return an org based on ID passed", func(t *testing.T) {
IDParam := uuid.New()
Expand Down Expand Up @@ -139,7 +178,7 @@ func TestService_GetDefaultOrgStateOnCreate(t *testing.T) {
mockPrefSvc := mocks.NewPreferencesService(t)

mockRoleSvc := mocks.NewRoleService(t)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc, avatar.Config{})

t.Run("should return org state to be set on creation, as per preferences", func(t *testing.T) {
expectedPrefs := map[string]string{
Expand Down Expand Up @@ -170,7 +209,7 @@ func TestService_AttachToPlatform(t *testing.T) {
mockPrefSvc := mocks.NewPreferencesService(t)

mockRoleSvc := mocks.NewRoleService(t)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc, mockPolicySvc, mockPrefSvc, mockRoleSvc, avatar.Config{})

inputOrgID := "some-org-id"
relationToBeCreated := relation.Relation{
Expand Down Expand Up @@ -212,7 +251,7 @@ func TestService_List(t *testing.T) {
mockPrefSvc := mocks.NewPreferencesService(t)
mockRoleSvc := mocks.NewRoleService(t)
svc := organization.NewService(mockRepo, mockRelationSvc, mockUserSvc, mockAuthnSvc,
mockPolicySvc, mockPrefSvc, mockRoleSvc)
mockPolicySvc, mockPrefSvc, mockRoleSvc, avatar.Config{})
return svc, mockRepo
}

Expand Down
11 changes: 10 additions & 1 deletion core/user/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"github.com/raystack/frontier/pkg/utils"

"github.com/raystack/frontier/core/auditrecord/models"
"github.com/raystack/frontier/core/avatar"
"github.com/raystack/frontier/core/relation"
"github.com/raystack/frontier/internal/bootstrap/schema"
pkgAuditRecord "github.com/raystack/frontier/pkg/auditrecord"
Expand Down Expand Up @@ -45,16 +46,18 @@ type Service struct {
relationService RelationService
sessionService SessionService
auditRecordRepository AuditRecordRepository
avatarConfig avatar.Config
Now func() time.Time
}

func NewService(repository Repository, relationRepo RelationService,
sessionService SessionService, auditRecordRepository AuditRecordRepository) *Service {
sessionService SessionService, auditRecordRepository AuditRecordRepository, avatarConfig avatar.Config) *Service {
return &Service{
repository: repository,
relationService: relationRepo,
sessionService: sessionService,
auditRecordRepository: auditRecordRepository,
avatarConfig: avatarConfig,
Now: func() time.Time {
return time.Now().UTC()
},
Expand Down Expand Up @@ -82,6 +85,9 @@ func (s Service) GetByEmail(ctx context.Context, email string) (User, error) {
}

func (s Service) Create(ctx context.Context, user User) (User, error) {
if err := avatar.Validate(user.Avatar, s.avatarConfig); err != nil {
return User{}, err
}
return s.repository.Create(ctx, User{
Name: strings.ToLower(user.Name),
Email: strings.ToLower(user.Email),
Expand All @@ -102,6 +108,9 @@ func (s Service) List(ctx context.Context, flt Filter) ([]User, error) {
// one security concern is that we need to ensure users can't misuse it to takeover
// invitations created for other users.
func (s Service) Update(ctx context.Context, toUpdate User) (User, error) {
if err := avatar.Validate(toUpdate.Avatar, s.avatarConfig); err != nil {
return User{}, err
}
id := toUpdate.ID
toUpdate.Email = strings.ToLower(toUpdate.Email)
toUpdate.Name = strings.ToLower(toUpdate.Name)
Expand Down
Loading
Loading