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:
2026-08-26 11:42:05 +03:00
co-authored by Claude Opus 5
parent 152aed5b87
commit fac56abb1d
5 changed files with 81 additions and 9 deletions
+13 -1
View File
@@ -11,7 +11,19 @@ const IdCookieName = "user_id"
func Cookies(id int64) (*http.Cookie, *http.Cookie) {
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 {
+25 -5
View File
@@ -136,6 +136,12 @@ func (s *Server) handleCreate(c echo.Context) error {
if err = video.Blur(ppath, s.privatePreviewPath(v.ID)); err != nil {
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))
}
@@ -180,14 +186,28 @@ func (s *Server) handleWatch(c echo.Context) error {
func (s *Server) handleVtt(c echo.Context) error {
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()
v, _ := s.db.GetVideo(ctx, vid)
if !s.haveAccess(ctx, uid, *v) {
v, err := s.db.GetVideo(ctx, vid)
// 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)
}
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)
}