fix: handle cookies correctly on onion mirrors

Match cookie domain to the current request host so onion mirrors fall back to host-only cookies instead of reusing the configured clearnet domain.

Also clear SID and PREFS cookies with explicit domain and path attributes to avoid leaving stale cookies behind when signing out or replacing anonymous preferences.

Refs: #1421
This commit is contained in:
khanhsnd 2026-05-11 21:44:09 +07:00
parent f914ce8040
commit 4aecf4e6f0
6 changed files with 73 additions and 18 deletions

View File

@ -3,6 +3,24 @@ require "../spec_helper"
CONFIG = Config.from_yaml(File.open("config/config.example.yml"))
Spectator.describe "Helper" do
describe "#cookie_domain" do
it "keeps the configured cookie domain for the matching host" do
expect(cookie_domain("example.com", "example.com")).to eq("example.com")
expect(cookie_domain("example.com", "example.com:3000")).to eq("example.com")
end
it "falls back to host-only cookies for unrelated hosts such as onion mirrors" do
expect(cookie_domain("example.com", "exampleonionaddress.onion")).to be_nil
expect(cookie_domain("example.com", "other.example")).to be_nil
end
it "preserves shared-subdomain deployments when the configured domain starts with a dot" do
expect(cookie_domain(".example.com", "example.com")).to eq(".example.com")
expect(cookie_domain(".example.com", "www.example.com")).to eq(".example.com")
expect(cookie_domain(".example.com", "deep.www.example.com")).to eq(".example.com")
end
end
describe "#produce_channel_search_continuation" do
it "correctly produces token for searching a specific channel" do
expect(produce_channel_search_continuation("UCXuqSBlHAE6Xw-yeJA0Tunw", "", 100)).to eq("4qmFsgJqEhhVQ1h1cVNCbEhBRTZYdy15ZUpBMFR1bncaIEVnWnpaV0Z5WTJnd0FUZ0JZQUY2QkVkS2IxaTRBUUE9WgCaAilicm93c2UtZmVlZFVDWHVxU0JsSEFFNlh3LXllSkEwVHVud3NlYXJjaA%3D%3D")

View File

@ -279,6 +279,26 @@ def sha256(text)
return digest.final.hexstring
end
def cookie_domain(configured_domain : String?, request_host : String?) : String?
return nil unless configured_domain
return nil unless request_host
normalized_domain = configured_domain.downcase.lchop(".")
normalized_host = request_host.downcase.sub(/:\d+\z/, "")
return configured_domain if normalized_host == normalized_domain
if configured_domain.starts_with?(".") && normalized_host.ends_with?(".#{normalized_domain}")
return configured_domain
end
nil
end
def cookie_domain(env : HTTP::Server::Context) : String?
cookie_domain(CONFIG.domain, env.request.headers["Host"]?)
end
def subscribe_pubsub(topic, key)
case topic
when .match(/^UC[A-Za-z0-9_-]{22}$/)

View File

@ -128,10 +128,9 @@ module Invidious::Routes::Account
Invidious::Database::SessionIDs.delete(email: user.email)
PG_DB.exec("DROP MATERIALIZED VIEW #{view_name}")
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
domain = cookie_domain(env)
env.response.cookies["SID"] = Invidious::User::Cookies.clear_sid(domain)
env.response.cookies["PREFS"] = Invidious::User::Cookies.clear_prefs(domain)
env.redirect referer
end

View File

@ -57,16 +57,14 @@ module Invidious::Routes::Login
sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32))
Invidious::Database::SessionIDs.insert(sid, email)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(cookie_domain(env), sid)
else
return error_template(401, "Wrong username or password")
end
# Since this user has already registered, we don't want to overwrite their preferences
if env.request.cookies["PREFS"]?
cookie = env.request.cookies["PREFS"]
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
env.response.cookies["PREFS"] = Invidious::User::Cookies.clear_prefs(cookie_domain(env))
end
else
if !CONFIG.registration_enabled
@ -123,15 +121,13 @@ module Invidious::Routes::Login
view_name = "subscriptions_#{sha256(user.email)}"
PG_DB.exec("CREATE MATERIALIZED VIEW #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}")
env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, sid)
env.response.cookies["SID"] = Invidious::User::Cookies.sid(cookie_domain(env), sid)
if env.request.cookies["PREFS"]?
user.preferences = env.get("preferences").as(Preferences)
Invidious::Database::Users.update_preferences(user)
cookie = env.request.cookies["PREFS"]
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
env.response.cookies["PREFS"] = Invidious::User::Cookies.clear_prefs(cookie_domain(env))
end
end
@ -164,10 +160,9 @@ module Invidious::Routes::Login
Invidious::Database::SessionIDs.delete(sid: sid)
env.request.cookies.each do |cookie|
cookie.expires = Time.utc(1990, 1, 1)
env.response.cookies << cookie
end
domain = cookie_domain(env)
env.response.cookies["SID"] = Invidious::User::Cookies.clear_sid(domain)
env.response.cookies["PREFS"] = Invidious::User::Cookies.clear_prefs(domain)
env.redirect referer
end

View File

@ -226,7 +226,7 @@ module Invidious::Routes::PreferencesRoute
File.write("config/config.yml", CONFIG.to_yaml)
end
else
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(cookie_domain(env), preferences)
end
env.redirect referer
@ -261,7 +261,7 @@ module Invidious::Routes::PreferencesRoute
preferences.dark_mode = "dark"
end
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(CONFIG.domain, preferences)
env.response.cookies["PREFS"] = Invidious::User::Cookies.prefs(cookie_domain(env), preferences)
end
if redirect

View File

@ -14,6 +14,7 @@ struct Invidious::User
return HTTP::Cookie.new(
name: "SID",
domain: domain,
path: "/",
value: sid,
expires: Time.utc + 2.years,
secure: SECURE,
@ -28,6 +29,7 @@ struct Invidious::User
return HTTP::Cookie.new(
name: "PREFS",
domain: domain,
path: "/",
value: URI.encode_www_form(preferences.to_json),
expires: Time.utc + 2.years,
secure: SECURE,
@ -35,5 +37,26 @@ struct Invidious::User
samesite: HTTP::Cookie::SameSite::Lax
)
end
def clear_sid(domain : String?) : HTTP::Cookie
clear("SID", domain, http_only: true)
end
def clear_prefs(domain : String?) : HTTP::Cookie
clear("PREFS", domain, http_only: false)
end
private def clear(name : String, domain : String?, http_only : Bool) : HTTP::Cookie
return HTTP::Cookie.new(
name: name,
domain: domain,
path: "/",
value: "",
expires: Time.utc(1990, 1, 1),
secure: SECURE,
http_only: http_only,
samesite: HTTP::Cookie::SameSite::Lax
)
end
end
end