Files
OwnCord/Server/service/user.go
T
Claude bdbd5ac472 fix(identity): 1 defect(s) (OC-0195)
Bound free-text profile fields by raw byte length before cleanText's
quadratic sanitizeToFixpoint pass runs, generalizing OC-0192's guard into
cleanTextBounded and applying it to HandlePresenceUpdate's custom_status,
SetCustomStatus, and group DM names.
2026-08-20 17:10:05 +00:00

347 lines
15 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"log/slog"
"strings"
"time"
"unicode/utf8"
"github.com/owncord/server/db"
"github.com/owncord/server/syncutil"
"github.com/owncord/server/telemetry"
)
// UserService handles user profile and session operations.
type UserService struct {
st Store
profileLocks keyedMutex
}
// NewUserService creates a UserService.
func NewUserService(st Store) *UserService {
return &UserService{st: st}
}
// keyedMutex hands out a per-key lock so unrelated keys never contend, while
// operations on the same key serialize. UpdateProfile uses one keyed by user
// ID: it is an unsynchronized read-merge-write (GetUserByID, merge the
// patch, UpdateUserProfile), and PATCH /users/me can race POST
// /users/me/avatar for the same user — without serialization, the loser's
// write commits columns merged against a pre-race snapshot, silently
// reverting whatever the winner just changed. Entries are never removed;
// the key space is bounded by distinct user IDs, not by request rate.
type keyedMutex struct {
mu syncutil.Mutex
locks map[int64]*syncutil.Mutex
}
// lock acquires the per-key lock and returns a func to release it.
func (k *keyedMutex) lock(key int64) func() {
k.mu.Lock()
if k.locks == nil {
k.locks = make(map[int64]*syncutil.Mutex)
}
l, ok := k.locks[key]
if !ok {
l = &syncutil.Mutex{}
k.locks[key] = l
}
k.mu.Unlock()
l.Lock()
return l.Unlock
}
// AvatarFileURL is the server-relative path an uploaded avatar is served from.
// It is the ordinary attachment route: the upload handler writes an attachment
// row and points users.avatar here, and handleServeFile admits an unlinked
// attachment that some user's avatar names. Defined once because three places
// have to agree on the spelling — the upload response, the stored column, and
// the file route's authorization probe, which matches the column *by string*.
func AvatarFileURL(fileID string) string {
return "/api/v1/files/" + fileID
}
// ─── Profile field bounds ───────────────────────────────────────────────────
const (
// MaxDisplayNameLen bounds users.display_name. 32 matches the username
// cap: a nickname that could not fit where a username fits would render
// clipped in exactly the places the fallback puts the username.
MaxDisplayNameLen = 32
// MaxAboutLen bounds users.about — long enough for a paragraph, short
// enough that the popup's two-line section stays a section.
MaxAboutLen = 300
// MaxCustomStatusLen bounds users.custom_status: one line under a name.
MaxCustomStatusLen = 128
)
// ProfilePatch is a partial update to a user's profile. A nil field means
// "leave unchanged"; a non-nil pointer to the empty string clears the nullable
// fields (display name, about). The free-text fields are sanitized and
// length-checked *here* rather than in the handler, so every transport that
// can reach a profile gets the same rules.
type ProfilePatch struct {
Username string
Avatar *string
DisplayName *string
About *string
}
// nullable turns a sanitized, trimmed patch value into the column value:
// empty string clears the column, anything else is stored as-is.
func nullable(v string) *string {
if v == "" {
return nil
}
return &v
}
// cleanText strips HTML and trims a free-text profile field. Both the profile
// PATCH and the presence path run values through it before any bound check, so
// a payload cannot buy length with markup that is about to be stripped anyway.
//
// Uses sanitizeToFixpoint (message.go), not a bare sanitizer.Sanitize call:
// display name, about, custom status, and DM group names all render through
// the client's textContent-only path (same as message content), so a plain
// sanitizer.Sanitize call would persist and display literal '/>/&
// entities for ordinary punctuation instead of the characters typed — the
// exact bug sanitizeToFixpoint fixes for message content.
func cleanText(v string) string {
return strings.TrimSpace(sanitizeToFixpoint(v))
}
// cleanTextBounded is cleanText plus the raw-byte guard OC-0192 established
// for UpdateProfile's DisplayName/About fields, generalized for every other
// free-text field that runs through cleanText: SetCustomStatus,
// HandlePresenceUpdate's custom_status, and group DM names (OC-0195).
//
// cleanText's sanitizeToFixpoint pass is quadratic in input length, so a
// bound applied only to its *output* (a plain rune-count check on the
// cleaned string) still lets an adversarial nested-entity payload pay the
// full sanitize cost first — it can even sanitize down to something well
// under maxRunes and be silently accepted, having spent seconds of CPU to
// get there. The byte-length pre-check runs before cleanText ever does, on
// the untouched input, so the cost of rejecting an oversized value is
// O(len(v)) instead of the sanitizer's cost. *4 is deliberately looser than
// maxRunes — it exists only to keep the sanitizer from ever seeing a
// pathological payload, not to duplicate the real (rune-count) bound, which
// still runs afterward on the cleaned, trimmed value.
func cleanTextBounded(v string, maxRunes int, fieldName string) (string, error) {
if len(v) > maxRunes*4 {
return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes)
}
cleaned := cleanText(v)
if utf8.RuneCountInString(cleaned) > maxRunes {
return "", fmt.Errorf("%w: %s must be at most %d characters", ErrBadRequest, fieldName, maxRunes)
}
return cleaned, nil
}
// resolveOptional picks the column value for one nullable text field: the
// sanitized patch when it was supplied, the existing row otherwise.
func resolveOptional(patch *string, existing *string) *string {
if patch == nil {
return existing
}
return nullable(cleanText(*patch))
}
// UpdateProfile applies a ProfilePatch: username and avatar as before, plus
// the nullable display name and about text. Returns the updated user for
// response building.
func (s *UserService) UpdateProfile(ctx context.Context, userID int64, patch ProfilePatch) (*db.User, error) {
ctx, span := telemetry.GlobalTracer("service/user").Start(ctx, "UserService.UpdateProfile",
telemetry.Int64("user_id", userID),
)
start := time.Now()
defer func() {
telemetry.TimeSince(ctx, telemetry.NewAppMetrics().ServiceCallDurationSec, start,
telemetry.String("method", "UpdateProfile"))
span.End()
}()
// OC-0192: bound the raw bytes before either reaches cleanText
// (sanitizeToFixpoint) below — its cost is quadratic in input length,
// and an adversarial nested-entity payload can sanitize down to
// something well under the rune-count bound while still costing seconds
// of CPU to get there, so the rune-count check alone never rejects it
// early. This is the same cheap byte-length pre-check the handler uses
// for username/avatar (profile_handler.go); *4 still admits any
// legitimate UTF-8 value at the rune bound. UpdateProfile is the one
// function every transport reaches (see ProfilePatch's doc comment), so
// the guard belongs here rather than only in the REST handler.
if patch.DisplayName != nil && len(*patch.DisplayName) > MaxDisplayNameLen*4 {
return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen)
}
if patch.About != nil && len(*patch.About) > MaxAboutLen*4 {
return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen)
}
if patch.DisplayName != nil && utf8.RuneCountInString(cleanText(*patch.DisplayName)) > MaxDisplayNameLen {
return nil, fmt.Errorf("%w: display_name must be at most %d characters", ErrBadRequest, MaxDisplayNameLen)
}
if patch.About != nil && utf8.RuneCountInString(cleanText(*patch.About)) > MaxAboutLen {
return nil, fmt.Errorf("%w: about must be at most %d characters", ErrBadRequest, MaxAboutLen)
}
// The update writes every column, so a partial patch has to be merged
// against the current row first — otherwise setting only a display name
// would silently clear the about text. That read-merge-write must be
// serialized per user: PATCH /users/me and POST /users/me/avatar both
// land here for the same account, and without a lock the second call's
// read can land between the first call's read and write, so its merge
// (built from the pre-race row) silently reverts the first call's change
// when it writes.
unlock := s.profileLocks.lock(userID)
defer unlock()
current, err := s.st.GetUserByID(ctx, userID)
if err != nil || current == nil {
return nil, fmt.Errorf("%w: user not found", ErrNotFound)
}
// An empty Username means "unspecified", the same as a nil
// DisplayName/About pointer — merged against the current row rather
// than written verbatim. This is what lets an avatar-only caller
// (handleUploadAvatar) leave username alone without handing over a
// snapshot that could be stale by the time this call lands: PATCH
// /users/me always validates and rejects an empty username before
// calling in, so "" never reaches here as a real rename request.
username := patch.Username
if username == "" {
username = current.Username
}
avatar := current.Avatar
if patch.Avatar != nil {
avatar = nullable(*patch.Avatar)
}
displayName := resolveOptional(patch.DisplayName, current.DisplayName)
about := resolveOptional(patch.About, current.About)
if err := s.st.UpdateUserProfile(ctx, userID, username, avatar, displayName, about); err != nil {
if db.IsUniqueConstraintError(err) {
return nil, fmt.Errorf("%w: username is already taken", ErrConflict)
}
return nil, fmt.Errorf("%w: failed to update profile: %v", ErrInternal, err)
}
user, err := s.st.GetUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("%w: failed to fetch updated user: %v", ErrInternal, err)
}
// Audit rows must survive a request canceled after the write committed.
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "profile_update", "user", userID,
fmt.Sprintf("username=%s", username))
slog.Info("profile updated", "user_id", userID, "username", username)
return user, nil
}
// SetCustomStatus stores (or clears, with an empty string) the user's custom
// status line. It is the presence path's counterpart to UpdateProfile: the
// value persists across reconnects and is cleared explicitly on logout, which
// is why it is stored rather than held on the connection.
func (s *UserService) SetCustomStatus(ctx context.Context, userID int64, text string) error {
cleaned, err := cleanTextBounded(text, MaxCustomStatusLen, "custom_status")
if err != nil {
return err
}
if err := s.st.UpdateUserCustomStatus(ctx, userID, nullable(cleaned)); err != nil {
return fmt.Errorf("%w: failed to update custom status: %v", ErrInternal, err)
}
return nil
}
// ClearCustomStatus wipes the custom status line. Called on logout: the text
// is a "what I am doing right now" note, and leaving it standing after the
// user signed out states something about them that is no longer true.
func (s *UserService) ClearCustomStatus(ctx context.Context, userID int64) error {
if err := s.st.UpdateUserCustomStatus(ctx, userID, nil); err != nil {
return fmt.Errorf("%w: failed to clear custom status: %v", ErrInternal, err)
}
return nil
}
// UpdateIdentityKey publishes the user's long-term E2EE identity public key
// (F3 voice E2EE TOFU). Last write wins; every write is audited so a key
// rotation — which peers surface as a TOFU mismatch — leaves a trail.
// Returns the updated user for response building.
func (s *UserService) UpdateIdentityKey(ctx context.Context, userID int64, key string) (*db.User, error) {
if err := s.st.UpdateUserIdentityKey(ctx, userID, &key); err != nil {
return nil, fmt.Errorf("%w: failed to update identity key", ErrInternal)
}
user, err := s.st.GetUserByID(ctx, userID)
if err != nil {
return nil, fmt.Errorf("%w: failed to fetch updated user", ErrInternal)
}
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "identity_key_update", "user", userID, "")
slog.Info("identity key published", "user_id", userID)
return user, nil
}
// ChangePasswordResult reports a completed password change. RevokeFailed is
// set when the password committed but other sessions could not be revoked —
// a partial success the caller must surface as a warning, never as a 5xx:
// the old password is already unusable, so telling the user the change
// "failed" walks them into retrying with a dead password and tripping the
// password-confirm lockout.
type ChangePasswordResult struct {
SessionsRevoked int64
RevokeFailed bool
}
// ChangePassword updates the user's password and revokes other sessions.
func (s *UserService) ChangePassword(ctx context.Context, userID int64, newPasswordHash string, keepSessionID int64) (ChangePasswordResult, error) {
if err := s.st.UpdateUserPassword(ctx, userID, newPasswordHash); err != nil {
return ChangePasswordResult{}, fmt.Errorf("%w: failed to update password: %v", ErrInternal, err)
}
// The password is committed from here on: every path below reports
// success and writes the audit row — even if the request ctx has been
// canceled, revocation and audit are the security tail of the change.
tailCtx := context.WithoutCancel(ctx)
var res ChangePasswordResult
revoked, err := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID)
res.SessionsRevoked = revoked
if err != nil {
slog.Error("UserService.ChangePassword DeleteOtherSessions", "err", err, "user_id", userID)
// One bounded compensating retry: revocation is the security tail of
// the change and a single immediate retry covers transient write-lock
// contention. ponytail: one retry, add backoff only if logs show it.
if revokedRetry, retryErr := s.st.DeleteOtherSessions(tailCtx, userID, keepSessionID); retryErr == nil {
res.SessionsRevoked += revokedRetry
} else {
res.RevokeFailed = true
}
}
db.WriteAudit(tailCtx, s.st, userID, "password_change", "user", userID, "password changed")
slog.Info("password changed", "user_id", userID,
"sessions_revoked", res.SessionsRevoked, "revoke_failed", res.RevokeFailed)
return res, nil
}
// ListSessions returns all active sessions for a user.
func (s *UserService) ListSessions(ctx context.Context, userID int64) ([]db.Session, error) {
sessions, err := s.st.ListUserSessions(ctx, userID)
if err != nil {
return nil, fmt.Errorf("%w: failed to list sessions: %v", ErrInternal, err)
}
return sessions, nil
}
// RevokeSession deletes a specific session owned by the user.
func (s *UserService) RevokeSession(ctx context.Context, userID, sessionID int64) error {
if err := s.st.DeleteSessionByID(ctx, sessionID, userID); err != nil {
if errors.Is(err, db.ErrNotFound) {
return fmt.Errorf("%w: session not found", ErrNotFound)
}
return fmt.Errorf("%w: failed to revoke session: %v", ErrInternal, err)
}
// Audit rows must survive a request canceled after the delete committed.
db.WriteAudit(context.WithoutCancel(ctx), s.st, userID, "session_revoke", "session", sessionID, "session revoked")
slog.Info("session revoked", "user_id", userID, "session_id", sessionID)
return nil
}