diff options
| author | Paul Buetow <paul@buetow.org> | 2026-05-20 14:10:49 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-05-20 14:10:49 +0300 |
| commit | 560d8ac3efeccb4e667444dec822c2bd454cb587 (patch) | |
| tree | 8e3b55156052dffc1341ce3cb67532e042e1f666 | |
| parent | b70dfdb80d897abf77b74a78eb59b984dfba64d4 (diff) | |
| parent | 7e65725253e40dce726d1be441b33b72d72ea8c4 (diff) | |
Merge j9+i9: LIKE wildcard escaping and CreateUser password validation
- escapeLike() helper in repository/media.go for LIKE search safety
- ErrWeakPassword sentinel in service.go (min 8 chars, HTTP 400)
- CreateUser rejects empty/short passwords in service/user.go
- ErrWeakPassword auto-dispatched via HTTPStatuser (no switch needed)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
| -rw-r--r-- | player-server/internal/api/handlers_auth.go | 14 | ||||
| -rw-r--r-- | player-server/internal/api/handlers_share.go | 10 | ||||
| -rw-r--r-- | player-server/internal/config.go | 5 | ||||
| -rw-r--r-- | player-server/internal/repository/media.go | 17 | ||||
| -rw-r--r-- | player-server/internal/service/admin_test.go | 2 | ||||
| -rw-r--r-- | player-server/internal/service/service.go | 1 | ||||
| -rw-r--r-- | player-server/internal/service/user.go | 8 | ||||
| -rw-r--r-- | player-server/internal/service/user_test.go | 24 | ||||
| -rw-r--r-- | player-server/internal/web/sharepage.go | 33 | ||||
| -rw-r--r-- | player-server/internal/web/sharepage_test.go | 44 |
10 files changed, 140 insertions, 18 deletions
diff --git a/player-server/internal/api/handlers_auth.go b/player-server/internal/api/handlers_auth.go index 3771877..c770380 100644 --- a/player-server/internal/api/handlers_auth.go +++ b/player-server/internal/api/handlers_auth.go @@ -191,16 +191,26 @@ func (s *Server) handleReadyz(w http.ResponseWriter, r *http.Request) { } func (s *Server) setSessionCookie(w http.ResponseWriter, value string) { + sessionDuration := time.Duration(s.cfg.SessionTimeoutHours) * time.Hour http.SetCookie(w, &http.Cookie{ Name: "session", Value: value, Path: "/", HttpOnly: true, Secure: s.cfg.SecureCookies, - SameSite: http.SameSiteStrictMode, + // SameSite=Lax allows cross-context navigation (e.g. mobile webviews, + // embedded players following a link) while still blocking most CSRF + // vectors. SameSite=Strict would drop the cookie on any cross-site + // top-level navigation, causing unnecessary session loss. + SameSite: http.SameSiteLaxMode, + // MaxAge is the authoritative persistence signal in modern browsers; + // Expires is the legacy fallback. Both are set to the same session + // duration so the cookie persists across browser restarts regardless + // of which attribute the client honours. + MaxAge: int(sessionDuration.Seconds()), // Use the injected clock so tests can assert the cookie Expires // value deterministically (no flakiness from time.Now()). - Expires: s.clk.Now().Add(time.Duration(s.cfg.SessionTimeoutHours) * time.Hour), + Expires: s.clk.Now().Add(sessionDuration), }) } diff --git a/player-server/internal/api/handlers_share.go b/player-server/internal/api/handlers_share.go index 03ec32a..ccf1632 100644 --- a/player-server/internal/api/handlers_share.go +++ b/player-server/internal/api/handlers_share.go @@ -7,6 +7,7 @@ import ( "time" "codeberg.org/snonux/player/internal/service" + "codeberg.org/snonux/player/internal/web" ) // ------------------------------------------------------------------ @@ -91,6 +92,15 @@ func (s *Server) handleSharePage(w http.ResponseWriter, r *http.Request) { return } + // Sanitize the media filename before embedding it in the share page to + // prevent XSS via HTML injection and to cap memory growth from enormous + // filenames (DoS). SanitizeFileName truncates to MaxFileNameLength runes + // and HTML-escapes the result; encoding/json also Unicode-escapes </> + // inside string values, so both layers reinforce each other. + if res.Media != nil { + res.Media.FileName = web.SanitizeFileName(res.Media.FileName) + } + // Render the HTML view via the dedicated renderer. This keeps the // handler focused on transport concerns (status codes, headers) and // keeps templating in the internal/web package. diff --git a/player-server/internal/config.go b/player-server/internal/config.go index 00c7beb..5388165 100644 --- a/player-server/internal/config.go +++ b/player-server/internal/config.go @@ -12,7 +12,10 @@ const ( DefaultPort = 8080 DefaultMediaRoot = "./media" DefaultDBPath = "data.db" - DefaultMaxUploadSizeMB = 100 + // DefaultMaxUploadSizeMB is 10 GB, chosen to accommodate large video files + // without imposing an arbitrary low cap. Operators can lower it via the + // MAX_UPLOAD_SIZE_MB environment variable for tighter constraints. + DefaultMaxUploadSizeMB = 10240 DefaultSessionTimeoutHours = 24 DefaultGCIntervalMinutes = 30 DefaultShareDefaultExpiryDays = 7 diff --git a/player-server/internal/repository/media.go b/player-server/internal/repository/media.go index c06d257..dd3d66f 100644 --- a/player-server/internal/repository/media.go +++ b/player-server/internal/repository/media.go @@ -181,12 +181,9 @@ func (s *SQLite) ListMedia(ctx context.Context, filter MediaFilter) ([]model.Med query := `SELECT DISTINCT media.id, media.set_id, media.rel_path, media.file_name, media.abs_path, media.type, media.duration, media.codec, media.resolution, media.bitrate, media.file_size_bytes, media.width, media.height, media.exif_camera, media.exif_lens, media.exif_date, media.exif_iso, media.exif_f_number, media.exif_exposure, media.exif_focal_length, media.thumbnail_path, media.play_count, media.deleted_at, media.created_at FROM media` if filter.Search != "" { + // escapeLike escapes LIKE wildcards so user input is treated as a literal substring. conds = append(conds, `(media.file_name LIKE ? ESCAPE '\' OR media.rel_path LIKE ? ESCAPE '\')`) - term := filter.Search - term = strings.ReplaceAll(term, "\\", "\\\\") - term = strings.ReplaceAll(term, "%", "\\%") - term = strings.ReplaceAll(term, "_", "\\_") - like := "%" + term + "%" + like := "%" + escapeLike(filter.Search) + "%" args = append(args, like, like) } if filter.Favorites { @@ -313,6 +310,16 @@ func incrementPlayCount(ctx context.Context, db sqlExecer, id int64) error { return nil } +// escapeLike escapes backslash, percent, and underscore in s so it can be +// used as a literal substring in a SQL LIKE ? ESCAPE '\' clause without +// allowing timing-based wildcard injection. +func escapeLike(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, `%`, `\%`) + s = strings.ReplaceAll(s, `_`, `\_`) + return s +} + func placeholders(n int) string { if n <= 0 { return "" diff --git a/player-server/internal/service/admin_test.go b/player-server/internal/service/admin_test.go index 0e4d309..1a93356 100644 --- a/player-server/internal/service/admin_test.go +++ b/player-server/internal/service/admin_test.go @@ -200,7 +200,7 @@ func TestAdminService_CreateUser(t *testing.T) { } hasher := &fakeHasher{fixed: "hashed", err: tt.hashErr} svc := NewAdminService(store, newMockClock(), hasher, nil, "", ctx) - user, err := svc.CreateUser(ctx, "alice", "secret", false) + user, err := svc.CreateUser(ctx, "alice", "strongpass", false) if tt.wantErr { if err == nil { t.Fatal("expected error") diff --git a/player-server/internal/service/service.go b/player-server/internal/service/service.go index beeb5aa..9792f95 100644 --- a/player-server/internal/service/service.go +++ b/player-server/internal/service/service.go @@ -48,6 +48,7 @@ var ( ErrInvalidCredentials = &apiError{msg: "invalid credentials", status: http.StatusUnauthorized} ErrInvalidFeed = &apiError{msg: "invalid feed", status: http.StatusBadRequest} ErrCannotDeleteSelf = &apiError{msg: "cannot delete self", status: http.StatusBadRequest} + ErrWeakPassword = &apiError{msg: "password must be at least 8 characters", status: http.StatusBadRequest} // ErrShareExpired is handled directly by share handlers (not via // handleError); it stays a plain sentinel because no dispatch metadata diff --git a/player-server/internal/service/user.go b/player-server/internal/service/user.go index 01467ed..ba3e541 100644 --- a/player-server/internal/service/user.go +++ b/player-server/internal/service/user.go @@ -10,6 +10,9 @@ import ( "codeberg.org/snonux/player/internal/repository" ) +// minPasswordLen is the minimum acceptable password length for new accounts. +const minPasswordLen = 8 + // userAdminService handles user account management. type userAdminService struct { store repository.UserAdminServiceStore @@ -27,6 +30,11 @@ func (s *userAdminService) ListUsers(ctx context.Context) ([]model.User, error) } func (s *userAdminService) CreateUser(ctx context.Context, username, password string, isAdmin bool) (*model.User, error) { + // Reject blank or short passwords before hashing to prevent weak account creation. + if len(password) < minPasswordLen { + return nil, ErrWeakPassword + } + hash, err := s.hasher.Hash(password) if err != nil { return nil, fmt.Errorf("hash password: %w", err) diff --git a/player-server/internal/service/user_test.go b/player-server/internal/service/user_test.go index 1e0ceb3..b470119 100644 --- a/player-server/internal/service/user_test.go +++ b/player-server/internal/service/user_test.go @@ -30,23 +30,37 @@ func TestUserAdminService_CreateUser(t *testing.T) { tests := []struct { name string + password string hashErr error createErr error wantErr bool }{ { - name: "ok", + name: "ok", + password: "strongpass", // 10 chars, meets 8-char minimum }, { - name: "hash error", - hashErr: errors.New("boom"), - wantErr: true, + name: "hash error", + password: "strongpass", + hashErr: errors.New("boom"), + wantErr: true, }, { name: "create error", + password: "strongpass", createErr: errors.New("boom"), wantErr: true, }, + { + name: "empty password rejected", + password: "", + wantErr: true, + }, + { + name: "short password rejected", + password: "short", + wantErr: true, + }, } for _, tt := range tests { @@ -60,7 +74,7 @@ func TestUserAdminService_CreateUser(t *testing.T) { } hasher := &fakeUserHasher{fixed: "hashed", err: tt.hashErr} svc := NewUserAdminService(store, clock.RealClock{}, hasher) - user, err := svc.CreateUser(ctx, "alice", "secret", false) + user, err := svc.CreateUser(ctx, "alice", tt.password, false) if tt.wantErr { if err == nil { t.Fatal("expected error") diff --git a/player-server/internal/web/sharepage.go b/player-server/internal/web/sharepage.go index 37844d8..455a768 100644 --- a/player-server/internal/web/sharepage.go +++ b/player-server/internal/web/sharepage.go @@ -13,12 +13,20 @@ package web import ( "encoding/json" "fmt" + "html" "io" "net/http" "strings" "time" ) +// MaxFileNameLength is the maximum number of runes kept from a media filename +// before it is truncated. Filenames beyond this limit can cause unbounded +// memory growth when embedded in the share-page HTML, so they are silently +// capped here. 255 is a common filesystem limit and a reasonable upper bound +// for display purposes. +const MaxFileNameLength = 255 + // ShareMediaPlaceholder is the HTML comment that gets substituted with // the JSON-encoded share metadata inside share.html. const ShareMediaPlaceholder = "<!--SHARE_MEDIA-->" @@ -82,25 +90,42 @@ func (r *SharePageRenderer) Render(data any) (RenderedPage, error) { return RenderedPage{}, fmt.Errorf("read share template: %w", err) } - html, err := injectShareMedia(buf.String(), r.placeholder, data) + rendered, err := injectShareMedia(buf.String(), r.placeholder, data) if err != nil { return RenderedPage{}, err } return RenderedPage{ - HTML: html, + HTML: rendered, ModTime: stat.ModTime(), Name: r.template, }, nil } +// SanitizeFileName truncates s to MaxFileNameLength runes and HTML-escapes +// the result. Both steps protect against DoS via enormous filenames and +// against HTML injection when the filename is embedded in a page attribute +// or element text context outside of the JSON-encoded script block. +func SanitizeFileName(s string) string { + runes := []rune(s) + if len(runes) > MaxFileNameLength { + runes = runes[:MaxFileNameLength] + } + return html.EscapeString(string(runes)) +} + // injectShareMedia replaces placeholder with the JSON-encoded form of // data, returning the new HTML. It is private to keep this package's // surface small: callers are expected to go through SharePageRenderer. -func injectShareMedia(html, placeholder string, data any) (string, error) { +// +// Go's encoding/json already escapes <, > and & as Unicode escapes inside +// string values, so the JSON blob is safe to embed in a <script> tag. +// The explicit SanitizeFileName call on the way in (see handlers_share.go) +// provides a second layer of defence and enforces a length cap. +func injectShareMedia(htmlDoc, placeholder string, data any) (string, error) { encoded, err := json.Marshal(data) if err != nil { return "", fmt.Errorf("marshal share metadata: %w", err) } - return strings.Replace(html, placeholder, string(encoded), 1), nil + return strings.Replace(htmlDoc, placeholder, string(encoded), 1), nil } diff --git a/player-server/internal/web/sharepage_test.go b/player-server/internal/web/sharepage_test.go index 7a89131..1dd7797 100644 --- a/player-server/internal/web/sharepage_test.go +++ b/player-server/internal/web/sharepage_test.go @@ -76,6 +76,50 @@ func TestInjectShareMedia(t *testing.T) { } } +func TestSanitizeFileName(t *testing.T) { + t.Run("short clean name unchanged", func(t *testing.T) { + got := SanitizeFileName("movie.mp4") + if got != "movie.mp4" { + t.Fatalf("expected %q, got %q", "movie.mp4", got) + } + }) + + t.Run("html special chars are escaped", func(t *testing.T) { + got := SanitizeFileName(`<script>alert(1)</script>.mp4`) + if strings.Contains(got, "<") || strings.Contains(got, ">") { + t.Fatalf("expected HTML-escaped output, got %q", got) + } + if !strings.Contains(got, "<") { + t.Fatalf("expected < in output, got %q", got) + } + }) + + t.Run("long filename is truncated to MaxFileNameLength runes", func(t *testing.T) { + // Build a filename longer than MaxFileNameLength characters. + long := strings.Repeat("a", MaxFileNameLength+100) + got := SanitizeFileName(long) + if len([]rune(got)) > MaxFileNameLength { + t.Fatalf("expected at most %d runes, got %d", MaxFileNameLength, len([]rune(got))) + } + }) + + t.Run("multibyte runes counted correctly", func(t *testing.T) { + // Each '日' is 3 UTF-8 bytes but 1 rune; we want exactly MaxFileNameLength runes. + long := strings.Repeat("日", MaxFileNameLength+10) + got := SanitizeFileName(long) + if len([]rune(got)) > MaxFileNameLength { + t.Fatalf("expected at most %d runes, got %d", MaxFileNameLength, len([]rune(got))) + } + }) + + t.Run("ampersand is escaped", func(t *testing.T) { + got := SanitizeFileName("a&b.mp4") + if got != "a&b.mp4" { + t.Fatalf("expected %q, got %q", "a&b.mp4", got) + } + }) +} + // statErrorFS returns a file whose Stat() fails — used to cover the // rare error path in Render where the template is openable but cannot // be stat'd. |
