diff --git a/check_tiktak.sh b/check_tiktak.sh new file mode 100755 index 0000000..e937a13 --- /dev/null +++ b/check_tiktak.sh @@ -0,0 +1,126 @@ +#!/usr/bin/env bash +# Smoke-test для tiktak. Запускать НА VULNBOX из каталога репы. +# ./check_tiktak.sh -> проверяет 127.0.0.1:5000 +# ./check_tiktak.sh 10.10.10.3 -> проверяет чужой/свой бокс по сети +# +# Повторяет сценарий чекера (register -> create -> watch -> vtt -> share по +# токену) и отдельно проверяет, что патчи действительно в задеплоенном билде. +set -uo pipefail + +HOST="${1:-127.0.0.1}" +BASE="http://${HOST}:5000" +TMP="$(mktemp -d)" +trap 'rm -rf "$TMP"' EXIT + +PASS=0; FAIL=0 +ok() { printf ' \033[32mOK\033[0m %s\n' "$1"; PASS=$((PASS+1)); } +bad() { printf ' \033[31mFAIL\033[0m %s\n' "$1"; FAIL=$((FAIL+1)); } +info() { printf '\n\033[1m%s\033[0m\n' "$1"; } + +req() { curl -sS -m 10 "$@"; } + +# ---------- тестовый webm ---------- +VIDEO="$TMP/clip.webm" +if ls services/tiktak/public/video/*.webm >/dev/null 2>&1; then + cp "$(ls -S services/tiktak/public/video/*.webm | tail -1)" "$VIDEO" +elif command -v ffmpeg >/dev/null 2>&1; then + ffmpeg -y -v error -f lavfi -i testsrc=size=320x240:rate=5 -t 1 -c:v libvpx "$VIDEO" "$VIDEO" 2>/dev/null +fi +[ -s "$VIDEO" ] || { echo "не смог получить тестовый webm — положи любой в services/tiktak/public/video/"; exit 1; } +echo "тестовое видео: $(wc -c < "$VIDEO") байт" + +# ---------- 1. живость ---------- +info "1. сервис отвечает" +code=$(req -o /dev/null -w '%{http_code}' "$BASE/") || true +[ "$code" = "302" ] && ok "GET / -> 302" || bad "GET / -> ${code:-нет ответа} (ждали 302)" +code=$(req -o /dev/null -w '%{http_code}' "$BASE/feed") +[ "$code" = "200" ] && ok "GET /feed -> 200" || bad "GET /feed -> $code" + +# ---------- 2. регистрация ---------- +info "2. регистрация и сессия" +U1="chk_$RANDOM$RANDOM"; J1="$TMP/j1" +HDR=$(req -D - -o /dev/null -c "$J1" -X POST "$BASE/register" -d "login=$U1&password=pw123456") +grep -q 'Location: /home' <<<"$HDR" && ok "register -> 302 /home" || bad "register не дал редирект на /home" +if grep -qi 'set-cookie:.*httponly' <<<"$HDR"; then + ok "куки с HttpOnly — ПАТЧИ В БИЛДЕ" +else + bad "куки БЕЗ HttpOnly — на боксе СТАРЫЙ билд, деплой не доехал" +fi +code=$(req -o /dev/null -w '%{http_code}' -b "$J1" "$BASE/home") +[ "$code" = "200" ] && ok "GET /home -> 200" || bad "GET /home -> $code" + +# ---------- 3. публичное видео ---------- +info "3. публичное видео: create -> watch -> vtt" +SUB="checker-canary-$RANDOM" +LOC=$(req -o /dev/null -w '%{redirect_url}' -b "$J1" -X POST "$BASE/create" \ + -F "description=smoke" -F "subtitles=$SUB" -F "video=@$VIDEO;type=video/webm") +VID="${LOC##*/}" +if [[ "$VID" =~ ^[0-9]+$ ]]; then ok "create -> /watch/$VID"; else bad "create не создал видео (redirect='$LOC')"; VID=""; fi +if [ -n "$VID" ]; then + body=$(req -b "$J1" "$BASE/watch/$VID") + grep -q 'smoke' <<<"$body" && ok "watch отдаёт description" || bad "watch без description" + vtt=$(req -b "$J1" "$BASE/vtt/?id=$VID") + grep -q 'WEBVTT' <<<"$vtt" && grep -q "$SUB" <<<"$vtt" && ok "vtt отдаёт субтитры" || bad "vtt не отдал субтитры" +fi + +# ---------- 4. приватное видео и шаринг ---------- +info "4. приватное видео: home -> token -> access -> watch" +PSUB="private-canary-$RANDOM" +LOC=$(req -o /dev/null -w '%{redirect_url}' -b "$J1" -X POST "$BASE/create" \ + -F "description=secret" -F "subtitles=$PSUB" -F "private=on" -F "video=@$VIDEO;type=video/webm") +PVID="${LOC##*/}" +if [[ "$PVID" =~ ^[0-9]+$ ]]; then ok "create private -> /watch/$PVID"; else bad "приватное видео не создалось"; PVID=""; fi +if [ -n "$PVID" ]; then + TOKEN=$(req -b "$J1" "$BASE/home" | grep -A0 'Share Token' | sed -n 's/.*Share Token: *\([A-Za-z0-9]\{30\}\).*/\1/p' | head -1) + [ -n "$TOKEN" ] && ok "токен на /home: $TOKEN" || bad "на /home нет Share Token" + + U2="chk2_$RANDOM$RANDOM"; J2="$TMP/j2" + req -o /dev/null -c "$J2" -X POST "$BASE/register" -d "login=$U2&password=pw123456" + code=$(req -o /dev/null -w '%{http_code}' -b "$J2" "$BASE/watch/$PVID") + [ "$code" = "403" ] && ok "чужой без токена -> 403" || bad "чужой без токена -> $code (ждали 403)" + + if [ -n "$TOKEN" ]; then + LOC=$(req -o /dev/null -w '%{redirect_url}' -b "$J2" -c "$J2" -X POST "$BASE/access" \ + -d "videoID=$PVID&token=$TOKEN") + if [[ "$LOC" == */watch/$PVID ]]; then + ok "access по токену -> /watch/$PVID" + grep -q 'secret' <<<"$(req -b "$J2" "$BASE/watch/$PVID")" && ok "шаринг работает" || bad "после access watch пустой" + else + bad "access отверг валидный токен (redirect='$LOC') — ЧЕКЕР СЛОМАЕТСЯ" + fi + fi +fi + +# ---------- 5. патчи ---------- +info "5. закрыты ли дыры" +for p in "./$VID" "x/../$VID" "/$VID" "../../etc/passwd"; do + code=$(req -o /dev/null -w '%{http_code}' -G "$BASE/vtt/" --data-urlencode "id=$p") + [ "$code" = "404" ] && ok "LFI '$p' -> 404" || bad "LFI '$p' -> $code (дыра ОТКРЫТА)" +done +if [ -n "$PVID" ]; then + code=$(req -o /dev/null -w '%{http_code}' "$BASE/public/static/preview_${PVID}.png") + [ "$code" = "404" ] && ok "резкое превью приватного -> 404" || bad "резкое превью отдаётся ($code)" + code=$(req -o /dev/null -w '%{http_code}' "$BASE/public/static/preview_${PVID}_blured.png") + [ "$code" = "200" ] && ok "размытое превью на месте" || bad "размытое превью -> $code (сломает фид)" +fi + +# ---------- 6. бомба ---------- +info "6. decompression bomb" +BOMB="$TMP/bomb.webm" +if command -v ffmpeg >/dev/null 2>&1 && \ + ffmpeg -y -v error -f lavfi -i color=c=black:s=8192x8192:d=1 -c:v libvpx "$BOMB" /dev/null && [ -s "$BOMB" ]; then + t0=$(date +%s) + code=$(req -o /dev/null -w '%{http_code}' -b "$J1" -X POST "$BASE/create" \ + -F "description=b" -F "subtitles=b" -F "video=@$BOMB;type=video/webm") + dt=$(( $(date +%s) - t0 )) + [ "$code" = "422" ] && ok "бомба 8192x8192 отбита за ${dt}s" || bad "бомба -> $code за ${dt}s (ждали 422)" +else + echo " -- пропуск: нет ffmpeg для генерации бомбы" +fi + +# ---------- итог ---------- +info "ИТОГ: $PASS ok, $FAIL fail" +[ "$FAIL" -eq 0 ] || exit 1 diff --git a/services/tiktak/db/db.go b/services/tiktak/db/db.go index ddcb312..6273d99 100644 --- a/services/tiktak/db/db.go +++ b/services/tiktak/db/db.go @@ -138,3 +138,11 @@ func (s *Storage) AddAccess(ctx context.Context, id int, userId int64) error { ExecContext(ctx) return err } + +// MaxVideoID reports the highest video id currently stored, 0 when empty. +func (s *Storage) MaxVideoID(ctx context.Context) (int64, error) { + sess := s.conn.NewSession(nil) + var id int64 + err := sess.Select("COALESCE(MAX(id), 0)").From("video").LoadOneContext(ctx, &id) + return id, err +} diff --git a/services/tiktak/keygen/keygen.go b/services/tiktak/keygen/keygen.go index 169d17b..f130521 100644 --- a/services/tiktak/keygen/keygen.go +++ b/services/tiktak/keygen/keygen.go @@ -38,5 +38,10 @@ func GenerateKey(vid int) string { func ValidateKey(token string, vid int) bool { // Constant-time: never leak how much of the token was correct. - return hmac.Equal([]byte(token), []byte(GenerateKey(vid))) + if hmac.Equal([]byte(token), []byte(GenerateKey(vid))) { + return true + } + // Grandfather in tokens handed out by the legacy algorithm, but only for + // videos that already existed when we switched. See legacy.go. + return legacyAccepted(token, vid) } diff --git a/services/tiktak/keygen/legacy.go b/services/tiktak/keygen/legacy.go new file mode 100644 index 0000000..d17138f --- /dev/null +++ b/services/tiktak/keygen/legacy.go @@ -0,0 +1,37 @@ +package keygen + +import "crypto/subtle" + +// Reconstructed from legacy/keygen.a (GenerateToken), kept ONLY for backwards +// compatibility: the checker may still hold tokens it was handed before the +// HMAC switch, and rejecting those costs SLA ("failed to get access using +// private key"). +// +// int seed = n; +// for (i = 0; i < 30; i++) { seed = (seed*17 + 42) % 62; out[i] = alpha[seed]; } +// +// The state collapses into n%62 after the first round, so this yields only 62 +// distinct tokens overall -- which is exactly why it had to go. +func legacyKey(vid int) string { + seed := vid + out := make([]byte, tokenLen) + for i := 0; i < tokenLen; i++ { + seed = (seed*17 + 42) % 62 + out[i] = alphabet[seed] + } + return string(out) +} + +// legacyCutoff is the highest video id that existed when the HMAC patch was +// deployed. Videos above it were never shared with a legacy token, so they must +// never accept one. 0 disables legacy acceptance entirely. +var legacyCutoff int + +func SetLegacyCutoff(id int) { legacyCutoff = id } + +func legacyAccepted(token string, vid int) bool { + if legacyCutoff <= 0 || vid <= 0 || vid > legacyCutoff { + return false + } + return subtle.ConstantTimeCompare([]byte(token), []byte(legacyKey(vid))) == 1 +} diff --git a/services/tiktak/keygen/legacy_test.go b/services/tiktak/keygen/legacy_test.go new file mode 100644 index 0000000..ee72653 --- /dev/null +++ b/services/tiktak/keygen/legacy_test.go @@ -0,0 +1,36 @@ +package keygen + +import "testing" + +func TestLegacyDualValidation(t *testing.T) { + // Reference values produced independently from the disassembly. + if got, want := legacyKey(1), "71npXhLZP5t3VJrvBfdFlRDNx9zHTb"; got != want { + t.Fatalf("legacyKey(1) = %q, want %q", got, want) + } + if legacyKey(1) != legacyKey(63) { + t.Fatal("legacy period should be 62") + } + + SetLegacyCutoff(0) + if ValidateKey(legacyKey(5), 5) { + t.Fatal("cutoff 0 must reject every legacy token") + } + + SetLegacyCutoff(100) + if !ValidateKey(legacyKey(5), 5) { + t.Fatal("old video must still accept its legacy token") + } + if ValidateKey(legacyKey(101), 101) { + t.Fatal("video created after the switch must NOT accept a legacy token") + } + if !ValidateKey(GenerateKey(101), 101) { + t.Fatal("new HMAC token must work above the cutoff") + } + if !ValidateKey(GenerateKey(5), 5) { + t.Fatal("new HMAC token must work below the cutoff too") + } + if ValidateKey(legacyKey(6), 5) || ValidateKey("garbage", 5) { + t.Fatal("wrong token accepted") + } + SetLegacyCutoff(0) +} diff --git a/services/tiktak/main.go b/services/tiktak/main.go index 50335c9..86eff07 100644 --- a/services/tiktak/main.go +++ b/services/tiktak/main.go @@ -6,10 +6,14 @@ import ( _ "github.com/go-sql-driver/mysql" "github.com/gocraft/dbr/v2" "github.com/thanhpk/randstr" + "io/ioutil" "net/http" "os" "os/signal" + "strconv" + "strings" "tiktak/db" + "tiktak/keygen" "tiktak/server" "tiktak/server/auth" "tiktak/video" @@ -29,13 +33,21 @@ func main() { } defer conn.Close() + storage := db.New(conn) + auth.SetSalt(randstr.Hex(20)) + + // Videos that already existed when the HMAC keygen was deployed may still + // be shared with a legacy token the checker is holding. Freeze that cutoff + // on first boot and persist it, so a container restart cannot widen the + // window (a wider window means more videos accepting forgeable tokens). + keygen.SetLegacyCutoff(legacyCutoff(storage)) config := server.Config{ StaticFolder: "public/static", VttFolder: "public/vtt", TemplatesFolder: "templates", } - srv := server.NewServer(db.New(conn), &video.Storage{Directory: "public/video"}, config) + srv := server.NewServer(storage, &video.Storage{Directory: "public/video"}, config) // Start server go func() { @@ -54,3 +66,27 @@ func main() { panic(err) } } + +// legacyCutoffFile lives on the ./public volume so it survives restarts. +const legacyCutoffFile = "public/.legacy_cutoff" + +func legacyCutoff(storage *db.Storage) int { + if b, err := ioutil.ReadFile(legacyCutoffFile); err == nil { + if n, err := strconv.Atoi(strings.TrimSpace(string(b))); err == nil { + fmt.Println("legacy token cutoff (pinned):", n) + return n + } + } + + id, err := storage.MaxVideoID(context.Background()) + if err != nil { + // Fail closed: no legacy tokens rather than an unbounded window. + fmt.Println("cannot read max video id, legacy tokens disabled:", err) + return 0 + } + if err := ioutil.WriteFile(legacyCutoffFile, []byte(strconv.FormatInt(id, 10)), 0644); err != nil { + fmt.Println("cannot pin legacy cutoff:", err) + } + fmt.Println("legacy token cutoff (fresh):", id) + return int(id) +} diff --git a/services/tiktak/video/preview.go b/services/tiktak/video/preview.go index 32b6b04..443355b 100644 --- a/services/tiktak/video/preview.go +++ b/services/tiktak/video/preview.go @@ -25,7 +25,11 @@ func generatePreview(ctx context.Context, inp string, out string) error { if err := checkGeometry(ctx, inp); err != nil { return err } - cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-threads", "1", "-i", inp, "-vframes", "1", out) + // -max_alloc caps a single ffmpeg allocation: a 1080p frame needs ~8 MB, a + // 16383x16383 bomb needs ~1 GB. Second line of defence behind checkGeometry, + // and it works even when the probe told us nothing. + cmd := exec.CommandContext(ctx, "ffmpeg", "-y", "-threads", "1", + "-max_alloc", "134217728", "-i", inp, "-vframes", "1", out) s := strings.Builder{} cmd.Stdout = &s cmd.Stderr = &s diff --git a/services/tiktak/video/probe.go b/services/tiktak/video/probe.go index b73b579..5172b85 100644 --- a/services/tiktak/video/probe.go +++ b/services/tiktak/video/probe.go @@ -20,16 +20,24 @@ func checkGeometry(ctx context.Context, inp string) error { args := []string{"-v", "error", "-select_streams", "v:0", "-show_entries", "stream=width,height", "-of", "csv=s=x:p=0", inp} + // FAIL-OPEN on purpose. A probe that errors out or prints something we do + // not understand must NOT reject the upload: the checker's video would + // start failing with "cannot create video" and that costs more than the + // bomb does. Only a geometry we successfully read and that is absurd is + // rejected. Bombs need a real, huge, parseable resolution to be bombs. out, err := exec.CommandContext(ctx, "ffprobe", args...).Output() if err != nil { - return fmt.Errorf("cannot probe video: %v", err) + return nil } var w, h int if _, err := fmt.Sscanf(strings.TrimSpace(string(out)), "%dx%d", &w, &h); err != nil { - return fmt.Errorf("cannot read video geometry") + return nil } - if w <= 0 || h <= 0 || w > MaxDimension || h > MaxDimension || w*h > MaxPixels { + if w <= 0 || h <= 0 { + return nil + } + if w > MaxDimension || h > MaxDimension || w*h > MaxPixels { return fmt.Errorf("video resolution %dx%d is not supported", w, h) } return nil