tiktak: LFI/auth-bypass в /vtt/, утечка превью, креды
- handleVtt: id валидируется как положительное целое, ошибка GetVideo
больше не игнорируется. Закрывает сразу два вектора: обход ACL через
нулевой db.Video (Private=false => haveAccess пропускал анонима) и
path traversal в path.Join(VttFolder, vid+".vtt").
- handleCreate: резкое превью приватного видео удаляется после блюра,
иначе оно оставалось доступным в public/static через echo.Static.
- main.go: DSN приведён к паролю из compose (5935004 поменял только
compose, из-за чего сервис не достучался бы до базы).
- db: пароли хешируются sha256 с pepper, сверка в Go constant-time,
плейнтекст принимается как legacy => старые юзеры не теряют доступ.
- cookies: HttpOnly + Path=/ + SameSite=Lax.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,9 +2,35 @@ package db
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/subtle"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
"github.com/gocraft/dbr/v2"
|
"github.com/gocraft/dbr/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// pepper is mixed into every stored password. NEVER change it after the first
|
||||||
|
// deploy -- every account created afterwards would be locked out.
|
||||||
|
const pepper = "cRazy_P4ssw0rd_MeOw_Me0W_676767676767"
|
||||||
|
|
||||||
|
// HashPassword returns the value that is actually persisted in users.password.
|
||||||
|
func HashPassword(password string) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(pepper))
|
||||||
|
h.Write([]byte(password))
|
||||||
|
return fmt.Sprintf("sha256$%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// passwordMatches accepts the new hashed form and, for accounts created before
|
||||||
|
// this patch, the legacy plaintext form -- otherwise existing users (including
|
||||||
|
// the checker's) would be locked out and the SLA would drop.
|
||||||
|
func passwordMatches(stored, supplied string) bool {
|
||||||
|
if subtle.ConstantTimeCompare([]byte(stored), []byte(HashPassword(supplied))) == 1 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return subtle.ConstantTimeCompare([]byte(stored), []byte(supplied)) == 1
|
||||||
|
}
|
||||||
|
|
||||||
type Storage struct {
|
type Storage struct {
|
||||||
conn *dbr.Connection
|
conn *dbr.Connection
|
||||||
}
|
}
|
||||||
@@ -30,6 +56,7 @@ func (s *Storage) init() {
|
|||||||
|
|
||||||
func (s *Storage) InsertUser(ctx context.Context, user *User) error {
|
func (s *Storage) InsertUser(ctx context.Context, user *User) error {
|
||||||
sess := s.conn.NewSession(nil)
|
sess := s.conn.NewSession(nil)
|
||||||
|
user.Password = HashPassword(user.Password)
|
||||||
_, err := sess.InsertInto("users").Columns("login", "password").Record(user).ExecContext(ctx)
|
_, err := sess.InsertInto("users").Columns("login", "password").Record(user).ExecContext(ctx)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -37,15 +64,19 @@ func (s *Storage) InsertUser(ctx context.Context, user *User) error {
|
|||||||
func (s *Storage) FindUser(ctx context.Context, login, password string) (*User, error) {
|
func (s *Storage) FindUser(ctx context.Context, login, password string) (*User, error) {
|
||||||
sess := s.conn.NewSession(nil)
|
sess := s.conn.NewSession(nil)
|
||||||
user := new(User)
|
user := new(User)
|
||||||
|
// Look the account up by login only, then verify the secret in Go: the
|
||||||
|
// password no longer travels through the WHERE clause as a comparable value.
|
||||||
err := sess.Select("*").
|
err := sess.Select("*").
|
||||||
From("users").
|
From("users").
|
||||||
Where("login = ?", login).
|
Where("login = ?", login).
|
||||||
Where("password = ?", password).
|
|
||||||
LoadOneContext(ctx, user)
|
LoadOneContext(ctx, user)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return user, err
|
if !passwordMatches(user.Password, password) {
|
||||||
|
return nil, dbr.ErrNotFound
|
||||||
|
}
|
||||||
|
return user, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Storage) AddVideo(ctx context.Context, v *Video) error {
|
func (s *Storage) AddVideo(ctx context.Context, v *Video) error {
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ version: '3'
|
|||||||
services:
|
services:
|
||||||
tiktak:
|
tiktak:
|
||||||
build: .
|
build: .
|
||||||
|
# CI (.gitea/workflows/build-and-push.yml) pushes only images matching
|
||||||
|
# git.itqdev.xyz/4x10m/* -- without this line the build job fails.
|
||||||
|
image: git.itqdev.xyz/4x10m/tiktak:${IMAGE_TAG:-latest}
|
||||||
ports:
|
ports:
|
||||||
- "5000:4000"
|
- "5000:4000"
|
||||||
restart: always
|
restart: always
|
||||||
@@ -13,8 +16,11 @@ services:
|
|||||||
db:
|
db:
|
||||||
image: mysql:8.0.17
|
image: mysql:8.0.17
|
||||||
restart: always
|
restart: always
|
||||||
|
# NOTE: not published to the host on purpose -- keep it that way.
|
||||||
volumes:
|
volumes:
|
||||||
- ./db_data:/var/lib/mysql
|
- ./db_data:/var/lib/mysql
|
||||||
environment:
|
environment:
|
||||||
|
# Rotated. Keep in sync with the DSN in main.go.
|
||||||
|
# Only applied when ./db_data is created from scratch!
|
||||||
MYSQL_ROOT_PASSWORD: cRazy_P4ssw0rd_MeOw_Me0W_676767676767
|
MYSQL_ROOT_PASSWORD: cRazy_P4ssw0rd_MeOw_Me0W_676767676767
|
||||||
MYSQL_DATABASE: tiktak
|
MYSQL_DATABASE: tiktak
|
||||||
|
|||||||
@@ -17,9 +17,12 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
|
// Rotated DB password. Must stay in sync with MYSQL_ROOT_PASSWORD in
|
||||||
|
// docker-compose.yml AND with the actual MySQL user (ALTER USER) when the
|
||||||
|
// ./db_data volume already exists.
|
||||||
conn, err := dbr.Open(
|
conn, err := dbr.Open(
|
||||||
"mysql",
|
"mysql",
|
||||||
"root:root@tcp(db:3306)/tiktak", nil)
|
"root:cRazy_P4ssw0rd_MeOw_Me0W_676767676767@tcp(db:3306)/tiktak", nil)
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
panic(err)
|
panic(err)
|
||||||
|
|||||||
@@ -11,7 +11,19 @@ const IdCookieName = "user_id"
|
|||||||
|
|
||||||
func Cookies(id int64) (*http.Cookie, *http.Cookie) {
|
func Cookies(id int64) (*http.Cookie, *http.Cookie) {
|
||||||
ids := strconv.FormatInt(id, 10)
|
ids := strconv.FormatInt(id, 10)
|
||||||
return &http.Cookie{Value: ids, Name: IdCookieName}, &http.Cookie{Value: Generate(ids), Name: HashCookieName}
|
return &http.Cookie{
|
||||||
|
Name: IdCookieName,
|
||||||
|
Value: ids,
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}, &http.Cookie{
|
||||||
|
Name: HashCookieName,
|
||||||
|
Value: Generate(ids),
|
||||||
|
Path: "/",
|
||||||
|
HttpOnly: true,
|
||||||
|
SameSite: http.SameSiteLaxMode,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type Context struct {
|
type Context struct {
|
||||||
|
|||||||
@@ -136,6 +136,12 @@ func (s *Server) handleCreate(c echo.Context) error {
|
|||||||
if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil {
|
if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil {
|
||||||
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to blur preview: " + err.Error()})
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to blur preview: " + err.Error()})
|
||||||
}
|
}
|
||||||
|
// public/static is served by echo.Static, so the sharp preview of a
|
||||||
|
// private video would otherwise be downloadable by anyone at
|
||||||
|
// /public/static/preview_<id>.png -- drop it once it has been blurred.
|
||||||
|
if err = os.Remove(ppath); err != nil && !os.IsNotExist(err) {
|
||||||
|
return c.Render(http.StatusUnprocessableEntity, "create", ErrorResponse{"failed to drop preview: " + err.Error()})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return c.Redirect(http.StatusFound, fmt.Sprintf("/watch/%d", v.ID))
|
return c.Redirect(http.StatusFound, fmt.Sprintf("/watch/%d", v.ID))
|
||||||
}
|
}
|
||||||
@@ -180,14 +186,28 @@ func (s *Server) handleWatch(c echo.Context) error {
|
|||||||
|
|
||||||
func (s *Server) handleVtt(c echo.Context) error {
|
func (s *Server) handleVtt(c echo.Context) error {
|
||||||
ac := c.(*auth.Context)
|
ac := c.(*auth.Context)
|
||||||
vid := c.QueryParam("id")
|
|
||||||
uid := ac.GetId()
|
// The id must be a plain positive integer. Anything else (./7, x/../7,
|
||||||
|
// ../../etc/foo) is rejected before it can reach either the DB or the FS.
|
||||||
|
vid, err := strconv.ParseInt(c.QueryParam("id"), 10, 64)
|
||||||
|
if err != nil || vid <= 0 {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
v, _ := s.db.GetVideo(ctx, vid)
|
v, err := s.db.GetVideo(ctx, vid)
|
||||||
if !s.haveAccess(ctx, uid, *v) {
|
// The error MUST be handled: a missing row leaves a zero db.Video
|
||||||
|
// (UserID=0, Private=false) which haveAccess() would happily accept.
|
||||||
|
if err != nil || v == nil {
|
||||||
|
return c.NoContent(http.StatusNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !s.haveAccess(ctx, ac.GetId(), *v) {
|
||||||
return c.NoContent(http.StatusForbidden)
|
return c.NoContent(http.StatusForbidden)
|
||||||
}
|
}
|
||||||
vttPath := path.Join(s.c.VttFolder, vid+".vtt")
|
|
||||||
|
// Build the path from the validated integer, never from raw user input.
|
||||||
|
vttPath := path.Join(s.c.VttFolder, strconv.FormatInt(vid, 10)+".vtt")
|
||||||
return c.File(vttPath)
|
return c.File(vttPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user