summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-20 14:10:49 +0300
committerPaul Buetow <paul@buetow.org>2026-05-20 14:10:49 +0300
commit560d8ac3efeccb4e667444dec822c2bd454cb587 (patch)
tree8e3b55156052dffc1341ce3cb67532e042e1f666
parentb70dfdb80d897abf77b74a78eb59b984dfba64d4 (diff)
parent7e65725253e40dce726d1be441b33b72d72ea8c4 (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.go14
-rw-r--r--player-server/internal/api/handlers_share.go10
-rw-r--r--player-server/internal/config.go5
-rw-r--r--player-server/internal/repository/media.go17
-rw-r--r--player-server/internal/service/admin_test.go2
-rw-r--r--player-server/internal/service/service.go1
-rw-r--r--player-server/internal/service/user.go8
-rw-r--r--player-server/internal/service/user_test.go24
-rw-r--r--player-server/internal/web/sharepage.go33
-rw-r--r--player-server/internal/web/sharepage_test.go44
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, "&lt;") {
+ t.Fatalf("expected &lt; 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&amp;b.mp4" {
+ t.Fatalf("expected %q, got %q", "a&amp;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.