Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / internal/modules/org/service.go |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 |
| Branch | feature/aioe-upgrade |
| Path or archive member | internal/modules/org/service.go |
| Original SHA-256 | bb37586dbbe97b8fd25cfc7a579e55163d4d5550a9aca2fb7db826ab25edf0e1 |
| Git blob SHA | 1fd4decd3d6fe5bd7bf174d2f0b53bafd515c0a6 |
| Reading-copy SHA-256 | bb37586dbbe97b8fd25cfc7a579e55163d4d5550a9aca2fb7db826ab25edf0e1 |
Recovered source transcript
Download complete source text ↓261 lines · 8.8 KB
// Package org serves per organization branding so the application white labels for every client that
// buys it. missionMATCH is sold to more than one client, so the mark in the shell is tenant data read
// from the organizations row, never anything hard coded.
//
// The security posture of this package is set by one fact: the logo is bytes uploaded by a customer and
// then served from the application origin. That makes it the only untrusted binary the app will ever
// return, so it is treated as hostile until proven otherwise.
//
// 1. The declared Content-Type from the client is IGNORED. The format is derived from the bytes by
// image.DecodeConfig, which reads only the header and fails on anything that is not a real raster
// image. An SVG or an HTML document renamed to logo.png does not decode and is rejected.
// 2. SVG is not accepted in any form. An SVG served same origin can carry a script element and become
// stored cross site scripting against every user in that organization.
// 3. Size and dimensions are bounded in Go before the insert, and again by CHECK constraints in
// migration 0046, so a bad row is impossible from either direction.
package org
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"image"
// Registering the decoders is what makes image.DecodeConfig able to identify these formats. Only
// these two are registered on purpose: GIF can be animated and SVG is not a raster format at all.
_ "image/jpeg"
_ "image/png"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/missionworkforce/missionmatch/internal/platform/audit"
"github.com/missionworkforce/missionmatch/internal/platform/tenant"
)
const (
// MaxLogoBytes matches the CHECK in migration 0046. Far beyond any real logo, small enough that a
// tenant cannot bloat the row or the backups.
MaxLogoBytes = 512 * 1024
// MaxLogoDimension guards a decompression bomb. DecodeConfig reads only the header, so a small file
// can still declare enormous dimensions; a downstream resize would then allocate that much memory.
MaxLogoDimension = 4096
// MaxDisplayName matches the CHECK in migration 0046.
MaxDisplayName = 120
)
var (
ErrNoDatabase = errors.New("org: no database configured")
ErrNoLogo = errors.New("org: no logo set")
ErrLogoTooLarge = errors.New("org: logo exceeds the maximum size")
ErrLogoFormat = errors.New("org: logo must be a PNG or JPEG image")
ErrLogoTooBig = errors.New("org: logo dimensions exceed the maximum")
ErrDisplayName = errors.New("org: display name must be between 1 and 120 characters")
)
// formatMIME maps the format name image.DecodeConfig reports onto the MIME we store and later serve.
// The map is the allowlist: a format that is not a key here cannot be stored, so adding a format is a
// deliberate edit rather than an accident of which decoders happen to be linked in.
var formatMIME = map[string]string{
"png": "image/png",
"jpeg": "image/jpeg",
}
// Branding is the tenant's brand identity for the application shell.
type Branding struct {
OrganizationID string `json:"organization_id"`
Name string `json:"name"`
DisplayName string `json:"display_name"`
HasLogo bool `json:"has_logo"`
LogoETag string `json:"logo_etag,omitempty"`
LogoUpdatedAt string `json:"logo_updated_at,omitempty"`
}
// Logo is the stored image plus the metadata needed to serve and cache it.
type Logo struct {
Bytes []byte
MIME string
ETag string
}
type Service struct {
db *tenant.DB
audit *audit.Writer
clock func() time.Time
}
func NewService(db *tenant.DB) *Service { return &Service{db: db, clock: time.Now} }
func NewServiceWithAudit(db *tenant.DB, aud *audit.Writer) *Service {
return &Service{db: db, audit: aud, clock: time.Now}
}
// etagFor is a strong validator over the exact bytes served. Deriving it from the content rather than
// from a timestamp means a rollback to a previous logo still busts the cache correctly.
func etagFor(b []byte) string {
sum := sha256.Sum256(b)
return `"` + hex.EncodeToString(sum[:16]) + `"`
}
// Branding reads the tenant's brand. It never returns the logo bytes, so the shell can decide whether to
// request the image without paying for it on every page load.
func (s *Service) Branding(ctx context.Context) (Branding, error) {
if s.db == nil {
return Branding{}, ErrNoDatabase
}
orgID, _ := tenant.OrgFromContext(ctx)
out := Branding{OrganizationID: orgID}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var display *string
var logo []byte
var updated *time.Time
// RLS restricts organizations to the caller's own row, so this reads one row without a predicate
// on id. A missing row means the GUC is wrong, which RunInTenantTx already refuses.
err := tx.QueryRow(ctx,
`SELECT name, display_name, logo, logo_updated_at FROM organizations`).
Scan(&out.Name, &display, &logo, &updated)
if err != nil {
return fmt.Errorf("read branding: %w", err)
}
if display != nil {
out.DisplayName = *display
}
if len(logo) > 0 {
out.HasLogo = true
out.LogoETag = etagFor(logo)
}
if updated != nil {
out.LogoUpdatedAt = updated.UTC().Format(time.RFC3339)
}
return nil
})
if err != nil {
return Branding{}, err
}
return out, nil
}
// Logo returns the stored image. The MIME comes from what the server derived at upload time, never from
// anything a caller supplied.
func (s *Service) Logo(ctx context.Context) (Logo, error) {
if s.db == nil {
return Logo{}, ErrNoDatabase
}
var out Logo
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var b []byte
var mime *string
if err := tx.QueryRow(ctx, `SELECT logo, logo_mime FROM organizations`).Scan(&b, &mime); err != nil {
return fmt.Errorf("read logo: %w", err)
}
if len(b) == 0 || mime == nil {
return ErrNoLogo
}
out = Logo{Bytes: b, MIME: *mime, ETag: etagFor(b)}
return nil
})
if err != nil {
return Logo{}, err
}
return out, nil
}
// SetLogo validates and stores an uploaded image.
//
// Validation order matters and is deliberate: cheap checks first so a hostile upload is rejected before
// any decoding work happens.
func (s *Service) SetLogo(ctx context.Context, raw []byte) (Branding, error) {
if s.db == nil {
return Branding{}, ErrNoDatabase
}
if len(raw) == 0 {
return Branding{}, ErrLogoFormat
}
if len(raw) > MaxLogoBytes {
return Branding{}, ErrLogoTooLarge
}
// The authoritative check. DecodeConfig reads only the header, so this is cheap and allocates
// nothing proportional to the declared dimensions. It fails on SVG, on HTML, and on any file whose
// extension or declared type disagrees with its actual content.
cfg, format, err := image.DecodeConfig(bytes.NewReader(raw))
if err != nil {
return Branding{}, ErrLogoFormat
}
mime, ok := formatMIME[format]
if !ok {
return Branding{}, ErrLogoFormat
}
if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > MaxLogoDimension || cfg.Height > MaxLogoDimension {
return Branding{}, ErrLogoTooBig
}
now := s.clock().UTC()
err = s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
ct, err := tx.Exec(ctx,
`UPDATE organizations SET logo = $1, logo_mime = $2, logo_updated_at = $3`,
raw, mime, now)
if err != nil {
return fmt.Errorf("store logo: %w", err)
}
if ct.RowsAffected() != 1 {
return fmt.Errorf("org: expected to update exactly one organization, updated %d", ct.RowsAffected())
}
return nil
})
if err != nil {
return Branding{}, err
}
return s.Branding(ctx)
}
// ClearLogo removes the stored image and falls the shell back to the product mark.
func (s *Service) ClearLogo(ctx context.Context) (Branding, error) {
if s.db == nil {
return Branding{}, ErrNoDatabase
}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
_, err := tx.Exec(ctx,
`UPDATE organizations SET logo = NULL, logo_mime = NULL, logo_updated_at = NULL`)
if err != nil {
return fmt.Errorf("clear logo: %w", err)
}
return nil
})
if err != nil {
return Branding{}, err
}
return s.Branding(ctx)
}
// SetDisplayName sets the brand name shown in the shell. An empty value clears it and falls back to the
// organization name, which is why blank is accepted here but a blank string is never stored.
func (s *Service) SetDisplayName(ctx context.Context, name string) (Branding, error) {
if s.db == nil {
return Branding{}, ErrNoDatabase
}
trimmed := strings.TrimSpace(name)
if len(trimmed) > MaxDisplayName {
return Branding{}, ErrDisplayName
}
err := s.db.RunInTenantTx(ctx, func(ctx context.Context, tx pgx.Tx) error {
var v any
if trimmed != "" {
v = trimmed
}
if _, err := tx.Exec(ctx, `UPDATE organizations SET display_name = $1`, v); err != nil {
return fmt.Errorf("store display name: %w", err)
}
return nil
})
if err != nil {
return Branding{}, err
}
return s.Branding(ctx)
}