From f3e6d6d84effbaad93345613f7da9cd8de30191d Mon Sep 17 00:00:00 2001 From: Fijxu Date: Mon, 14 Sep 2026 23:04:25 -0300 Subject: [PATCH] feat: sanity check video IDs with regex (#6037) * feat: sanity check video IDs with regex * Return 400 instead * fix wrong function * add coderabbit suggestion about PCRE2 regex and length of the string with a new line * Use validate_video_id on more places * Add video id validation to /watch route. --- src/invidious/exceptions.cr | 12 ++++++++++ src/invidious/helpers/utils.cr | 7 ++++++ src/invidious/routes/api/v1/authenticated.cr | 18 ++++++++------- src/invidious/routes/api/v1/videos.cr | 23 ++++++++++---------- src/invidious/routes/video_playback.cr | 4 ++-- src/invidious/routes/watch.cr | 12 +++------- 6 files changed, 46 insertions(+), 30 deletions(-) diff --git a/src/invidious/exceptions.cr b/src/invidious/exceptions.cr index 690db907..86ce20c2 100644 --- a/src/invidious/exceptions.cr +++ b/src/invidious/exceptions.cr @@ -38,3 +38,15 @@ end # some important informations, and that the query should be sent again. class RetryOnceException < Exception end + +# Exception for invalid video IDs. +class InvalidVideoID < InfoException + getter id : String? + + def initialize(@id) + end + + def message + return "Invalid video ID '#{id}'" + end +end diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index 7a262f84..6973a93f 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -404,3 +404,10 @@ def invidious_companion_encrypt(data) encrypted_data = encrypt_ecb_without_salt("#{timestamp}|#{data}", CONFIG.invidious_companion_key) return Base64.urlsafe_encode(encrypted_data) end + +def validate_video_id(id : String) : Bool + # This is the video ID regex. May be need to be changed + # if Youtube ever decides to add more characters to their + # video IDs. + /\A[a-zA-Z0-9_-]{11}\z/.matches?(id) +end diff --git a/src/invidious/routes/api/v1/authenticated.cr b/src/invidious/routes/api/v1/authenticated.cr index fcefc118..ebbbc2a7 100644 --- a/src/invidious/routes/api/v1/authenticated.cr +++ b/src/invidious/routes/api/v1/authenticated.cr @@ -81,9 +81,10 @@ module Invidious::Routes::API::V1::Authenticated return error_json(409, "Watch history is disabled in preferences.") end + # Sanity checks id = env.params.url["id"] - if !id.match(/^[a-zA-Z0-9_-]{11}$/) - return error_json(400, "Invalid video id.") + unless validate_video_id(id) + return error_json(400, InvalidVideoID.new(id)) end Invidious::Database::Users.mark_watched(user, id) @@ -97,12 +98,12 @@ module Invidious::Routes::API::V1::Authenticated return error_json(409, "Watch history is disabled in preferences.") end - id = env.params.url["id"] - if !id.match(/^[a-zA-Z0-9_-]{11}$/) - return error_json(400, "Invalid video id.") + video_id = env.params.url["id"] + unless video_id && validate_video_id(video_id) + return error_json(400, InvalidVideoID.new(video_id)) end - Invidious::Database::Users.mark_unwatched(user, id) + Invidious::Database::Users.mark_unwatched(user, video_id) env.response.status_code = 204 end @@ -309,8 +310,9 @@ module Invidious::Routes::API::V1::Authenticated end video_id = env.params.json["videoId"].try &.as(String) - if !video_id - return error_json(403, "Invalid videoId") + # Sanity checks + unless video_id && validate_video_id(video_id) + return error_json(400, InvalidVideoID.new(video_id)) end begin diff --git a/src/invidious/routes/api/v1/videos.cr b/src/invidious/routes/api/v1/videos.cr index 0a5d5a4e..5600e30d 100644 --- a/src/invidious/routes/api/v1/videos.cr +++ b/src/invidious/routes/api/v1/videos.cr @@ -32,8 +32,9 @@ module Invidious::Routes::API::V1::Videos id = env.params.url["id"] region = env.params.query["region"]? || env.params.body["region"]? - if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) - return error_json(400, "Invalid video ID") + # Sanity checks + unless validate_video_id(id) + return error_json(400, InvalidVideoID.new(id)) end # See https://github.com/ytdl-org/youtube-dl/blob/6ab30ff50bf6bd0585927cb73c7421bef184f87a/youtube_dl/extractor/youtube.py#L1354 @@ -256,11 +257,11 @@ module Invidious::Routes::API::V1::Videos def self.annotations(env) env.response.content_type = "text/xml" - id = env.params.url["id"] + video_id = env.params.url["id"] source = env.params.query["source"]? source ||= "archive" - if !id.match(/[a-zA-Z0-9_-]{11}/) + unless video_id && validate_video_id(video_id) haltf env, 400 end @@ -268,21 +269,21 @@ module Invidious::Routes::API::V1::Videos case source when "archive" - if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(id)) + if CONFIG.cache_annotations && (cached_annotation = Invidious::Database::Annotations.select(video_id)) annotations = cached_annotation.annotations else - index = CHARS_SAFE.index!(id[0]).to_s.rjust(2, '0') + index = CHARS_SAFE.index!(video_id[0]).to_s.rjust(2, '0') # IA doesn't handle leading hyphens, # so we use https://archive.org/details/youtubeannotations_64 if index == "62" index = "64" - id = id.sub(/^-/, 'A') + video_id = video_id.sub(/^-/, 'A') end - file = URI.encode_www_form("#{id[0, 3]}/#{id}.xml") + file = URI.encode_www_form("#{video_id[0, 3]}/#{video_id}.xml") - location = make_client(INTERNET_ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{id[0, 2]}.tar/#{file}")) + location = make_client(INTERNET_ARCHIVE_URL, &.get("/download/youtubeannotations_#{index}/#{video_id[0, 2]}.tar/#{file}")) if !location.headers["Location"]? env.response.status_code = location.status_code @@ -300,10 +301,10 @@ module Invidious::Routes::API::V1::Videos annotations = response.body - Helpers.cache_annotation(id, annotations) + Helpers.cache_annotation(video_id, annotations) end else # "youtube" - response = YT_POOL.client &.get("/annotations_invideo?video_id=#{id}") + response = YT_POOL.client &.get("/annotations_invideo?video_id=#{video_id}") if response.status_code != 200 haltf env, response.status_code diff --git a/src/invidious/routes/video_playback.cr b/src/invidious/routes/video_playback.cr index f16a5a58..f7909c8e 100644 --- a/src/invidious/routes/video_playback.cr +++ b/src/invidious/routes/video_playback.cr @@ -268,8 +268,8 @@ module Invidious::Routes::VideoPlayback itag = env.params.query["itag"]?.try &.to_i? # Sanity checks - if id.nil? || id.size != 11 || !id.matches?(/^[\w-]+$/) - return error_template(400, "Invalid video ID") + unless id && validate_video_id(id) + return error_template(400, InvalidVideoID.new(id)) end if !itag.nil? && (itag <= 0 || itag >= 1000) diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 3c66f1bc..4bca3753 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -18,14 +18,8 @@ module Invidious::Routes::Watch return error_template(400, "Invalid parameters.") end - if id.size > 11 - url = "/watch?v=#{id[0, 11]}" - env.params.query.delete_all("v") - if env.params.query.size > 0 - url += "&#{env.params.query}" - end - - return env.redirect url + unless validate_video_id(id) + return error_template(400, InvalidVideoID.new(id)) end else return env.redirect "/" @@ -235,7 +229,7 @@ module Invidious::Routes::Watch token = env.params.body["csrf_token"]? id = env.params.query["id"]? - if !id + unless id && validate_video_id(id) env.response.status_code = 400 return end