fix: harden bot accounts and download handling

This commit is contained in:
ShukeBta
2026-06-07 18:30:27 +08:00
parent 7e37126f7c
commit df02fd1166
26 changed files with 1373 additions and 136 deletions
+99 -8
View File
@@ -6,6 +6,8 @@
package handler
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
@@ -104,9 +106,11 @@ func embyPingHandler(_ *service.Container) gin.HandlerFunc {
// ─── Users / Auth ────────────────────────────────────────────────────────────
type embyAuthByNameReq struct {
Username string `json:"Username"`
Pw string `json:"Pw"`
Password string `json:"Password"`
Username string `json:"Username"`
Pw string `json:"Pw"`
Password string `json:"Password"`
PasswordMd5 string `json:"PasswordMd5"`
PasswordSha1 string `json:"PasswordSha1"`
}
func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
@@ -116,12 +120,10 @@ func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) {
return req, err
}
req.Username = firstStringFromMap(body, "Username", "username", "Name", "name")
req.Pw = firstStringFromMap(body, "Pw", "pw")
req.Password = firstStringFromMap(body, "Password", "password")
fillEmbyAuthFromMap(&req, body)
}
if req.Username == "" || (req.Pw == "" && req.Password == "") {
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
_ = c.Request.ParseForm()
if req.Username == "" {
req.Username = firstFormValue(c, "Username", "username", "Name", "name")
@@ -132,6 +134,12 @@ func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
if req.Password == "" {
req.Password = firstFormValue(c, "Password", "password")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstFormValue(c, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstFormValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
}
}
if req.Username == "" {
@@ -143,9 +151,88 @@ func parseEmbyAuthByNameReq(c *gin.Context) (embyAuthByNameReq, error) {
if req.Password == "" {
req.Password = firstQueryValue(c, "Password", "password")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstQueryValue(c, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstQueryValue(c, "PasswordSha1", "passwordSha1", "password_sha1")
}
if req.Username == "" || (req.Pw == "" && req.Password == "" && req.PasswordMd5 == "" && req.PasswordSha1 == "") {
fillEmbyAuthFromRawBody(c, &req)
}
return req, nil
}
func fillEmbyAuthFromMap(req *embyAuthByNameReq, body map[string]any) {
if req.Username == "" {
req.Username = firstStringFromMap(body, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
}
if req.Pw == "" {
req.Pw = firstStringFromMap(body, "Pw", "pw", "PW")
}
if req.Password == "" {
req.Password = firstStringFromMap(body, "Password", "password", "Pass", "pass", "Pwd", "pwd")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstStringFromMap(body, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstStringFromMap(body, "PasswordSha1", "passwordSha1", "password_sha1")
}
}
func fillEmbyAuthFromRawBody(c *gin.Context, req *embyAuthByNameReq) {
if c.Request == nil || c.Request.Body == nil {
return
}
raw, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(raw))
raw = bytes.TrimSpace(raw)
if len(raw) == 0 {
return
}
if bytes.HasPrefix(raw, []byte("{")) {
var body map[string]any
if err := json.Unmarshal(raw, &body); err == nil {
fillEmbyAuthFromMap(req, body)
}
return
}
if values, err := url.ParseQuery(string(raw)); err == nil {
fillEmbyAuthFromValues(req, values)
}
}
func fillEmbyAuthFromValues(req *embyAuthByNameReq, values url.Values) {
if req.Username == "" {
req.Username = firstValue(values, "Username", "username", "UserName", "userName", "Name", "name", "LoginName", "loginName")
}
if req.Pw == "" {
req.Pw = firstValue(values, "Pw", "pw", "PW")
}
if req.Password == "" {
req.Password = firstValue(values, "Password", "password", "Pass", "pass", "Pwd", "pwd")
}
if req.PasswordMd5 == "" {
req.PasswordMd5 = firstValue(values, "PasswordMd5", "passwordMd5", "password_md5")
}
if req.PasswordSha1 == "" {
req.PasswordSha1 = firstValue(values, "PasswordSha1", "passwordSha1", "password_sha1")
}
}
func firstValue(values url.Values, keys ...string) string {
for _, key := range keys {
if value := strings.TrimSpace(values.Get(key)); value != "" {
return value
}
}
return ""
}
func firstStringFromMap(body map[string]any, keys ...string) string {
if len(body) == 0 {
return ""
@@ -196,6 +283,10 @@ func embyAuthByNameHandler(svc *service.Container) gin.HandlerFunc {
password = req.Password
}
if strings.TrimSpace(req.Username) == "" || password == "" {
if req.PasswordMd5 != "" || req.PasswordSha1 != "" {
embyError(c, http.StatusBadRequest, "plain password required")
return
}
embyError(c, http.StatusBadRequest, "missing username or password")
return
}
@@ -909,7 +1000,7 @@ func registerEmbyRoutes(r *gin.Engine, jwtSecret string, svc *service.Container)
// 30/min per IP: many Emby clients sit behind a single NAT/reverse-proxy
// IP, so a low limit would throttle legitimate logins into 429s.
embyLoginLimiter := middleware.NewRateLimiter(30, 1*time.Minute)
for _, path := range []string{"/Users/AuthenticateByName", "/users/authenticatebyname"} {
for _, path := range []string{"/Users/AuthenticateByName", "/Users/authenticatebyname", "/users/AuthenticateByName", "/users/authenticatebyname"} {
grp.POST(path, middleware.RateLimit(embyLoginLimiter), embyAuthByNameHandler(svc))
}
for _, path := range []string{"/Users/Public", "/users/public"} {
+60
View File
@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -55,6 +56,65 @@ func TestParseEmbyAuthByNameReqAcceptsFormBody(t *testing.T) {
}
}
func TestParseEmbyAuthByNameReqAcceptsJSONWithoutContentType(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"UserName":"carol","PW":"secret"}`))
req, err := parseEmbyAuthByNameReq(c)
if err != nil {
t.Fatalf("parseEmbyAuthByNameReq returned error: %v", err)
}
if req.Username != "carol" || req.Pw != "secret" {
t.Fatalf("unexpected request: %#v", req)
}
}
func TestEmbyAuthenticateByNameAcceptsCaseVariantUsernameAndPath(t *testing.T) {
gin.SetMode(gin.TestMode)
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open db: %v", err)
}
if err := db.AutoMigrate(&model.User{}, &model.UserPermission{}, &model.RefreshToken{}, &model.Setting{}); err != nil {
t.Fatalf("migrate: %v", err)
}
repos := repository.New(db)
cfg := &config.Config{}
cfg.Secrets.JWTSecret = "test-secret"
log := zap.NewNop()
permissions := service.NewPermissionService(log, repos)
auth := service.NewAuthService(cfg, log, repos, service.NewTokenService(cfg, log, repos), permissions)
if _, _, err := auth.Register(context.Background(), "viewer", "secret-pass"); err != nil {
t.Fatalf("register: %v", err)
}
router := gin.New()
registerEmbyRoutes(router, cfg.Secrets.JWTSecret, &service.Container{
Repo: repos,
Auth: auth,
Emby: service.NewEmbyService(cfg, log, repos),
Audit: service.NewAuditService(log, repos),
})
req := httptest.NewRequest(http.MethodPost, "/emby/users/authenticatebyname", strings.NewReader(`{"Username":"Viewer","Pw":"secret-pass"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("unexpected status: %d body=%s", w.Code, w.Body.String())
}
var payload map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
t.Fatalf("decode response: %v", err)
}
if payload["AccessToken"] == "" {
t.Fatalf("missing AccessToken: %#v", payload)
}
}
func TestEmbyWithRequestAddressUsesHost(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
+21 -1
View File
@@ -5,6 +5,7 @@ import (
"context"
"errors"
"net/http"
"strings"
"github.com/gin-gonic/gin"
@@ -26,7 +27,12 @@ func updateProfileHandler(svc *service.Container) gin.HandlerFunc {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if hideAdultChanged {
usernameChanged, err := profileUsernameChanged(c.Request.Context(), svc, userID, patch)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if hideAdultChanged || usernameChanged {
if err := svc.Auth.VerifyPassword(c.Request.Context(), userID, patch.Password); err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "需要输入当前账号密码确认"})
return
@@ -59,6 +65,20 @@ func profileHideAdultChanged(ctx context.Context, svc *service.Container, userID
return user.HideAdult != *patch.HideAdult, nil
}
func profileUsernameChanged(ctx context.Context, svc *service.Container, userID string, patch service.ProfileUpdate) (bool, error) {
if patch.Username == nil {
return false, nil
}
user, err := svc.Repo.User.FindByID(ctx, userID)
if err != nil {
return false, err
}
if user == nil {
return false, errors.New("user not found")
}
return user.Username != strings.TrimSpace(*patch.Username), nil
}
type adminUpdateRoleReq struct {
Role string `json:"role" binding:"required"`
}
+34
View File
@@ -44,3 +44,37 @@ func TestProfileHideAdultRequiresPasswordOnlyWhenChanged(t *testing.T) {
t.Fatal("changed hide_adult value should require password")
}
}
func TestProfileUsernameChangeRequiresPasswordOnlyWhenChanged(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatal(err)
}
if err := db.AutoMigrate(&model.User{}); err != nil {
t.Fatal(err)
}
repos := repository.New(db)
user := &model.User{Username: "viewer", PasswordHash: "hash", Role: "user", HideAdult: true}
if err := repos.User.Create(t.Context(), user); err != nil {
t.Fatal(err)
}
svc := &service.Container{Repo: repos}
same := " viewer "
changed, err := profileUsernameChanged(t.Context(), svc, user.ID, service.ProfileUpdate{Username: &same})
if err != nil {
t.Fatalf("same username returned error: %v", err)
}
if changed {
t.Fatal("same username after trimming should not require password")
}
next := "renamed"
changed, err = profileUsernameChanged(t.Context(), svc, user.ID, service.ProfileUpdate{Username: &next})
if err != nil {
t.Fatalf("changed username returned error: %v", err)
}
if !changed {
t.Fatal("changed username should require password")
}
}