diff --git a/config/config.example.yml b/config/config.example.yml index 56c516293..a2f10a608 100644 --- a/config/config.example.yml +++ b/config/config.example.yml @@ -370,6 +370,27 @@ https_only: false ## #login_enabled: true +## +## Trusted-header authentication (ArikTube extension). +## +## An authenticating reverse proxy (for example Authelia) asserts the +## user name in a request header. The header is only honored when the +## direct TCP peer is listed in trusted_proxies (literal IP addresses, +## no CIDR ranges). Accounts are created on first sight. /api/ routes +## are excluded — API clients keep using tokens. +## +## WARNING: the reverse proxy MUST strip this header from client +## requests on every route that bypasses its authentication. +## +## Default: disabled +## +#trusted_header_auth: +# enabled: true +# header: Remote-User +# trusted_proxies: +# - 192.168.1.101 +# logout_url: "https://auth.example.com/logout" + ## ## Enable/Disable the captcha challenge on the login page. ## diff --git a/src/invidious/config.cr b/src/invidious/config.cr index 58b77cba8..04ddd0cbf 100644 --- a/src/invidious/config.cr +++ b/src/invidious/config.cr @@ -80,6 +80,21 @@ struct HTTPProxyConfig property port : Int32 end +# Trusted-header authentication (ArikTube extension). An authenticating +# reverse proxy (Authelia) asserts the user name in a request header. +struct TrustedHeaderAuthConfig + include YAML::Serializable + + property enabled : Bool = false + # Request header that carries the authenticated user name + property header : String = "Remote-User" + # Literal peer IPs allowed to assert the header. The TCP peer address is + # compared, never X-Forwarded-For. CIDR ranges are not supported. + property trusted_proxies : Array(String) = [] of String + # Optional target for the sign-out link while header auth is active + property logout_url : String? = nil +end + class Config include YAML::Serializable @@ -135,6 +150,8 @@ class Config property captcha_enabled : Bool = true property login_enabled : Bool = true property registration_enabled : Bool = true + # Trusted-header authentication (ArikTube extension) + property trusted_header_auth : TrustedHeaderAuthConfig = TrustedHeaderAuthConfig.from_yaml("") property statistics_enabled : Bool = false property admins : Array(String) = [] of String property external_port : Int32? = nil @@ -325,6 +342,24 @@ class Config end end + # Trusted-header auth (ArikTube extension): fail closed on misconfiguration + if config.trusted_header_auth.enabled + if config.trusted_header_auth.header.strip.empty? + puts "Config: trusted_header_auth.header can't be empty" + exit(1) + end + if config.trusted_header_auth.trusted_proxies.empty? + puts "Config: trusted_header_auth needs at least one trusted_proxies entry" + exit(1) + end + config.trusted_header_auth.trusted_proxies.each do |address| + if address.includes?('/') || !Socket::IPAddress.valid?(address) + puts "Config: trusted_header_auth.trusted_proxies takes literal IP addresses only (got '#{address}')" + exit(1) + end + end + end + # Check if the socket configuration is valid if sb = config.socket_binding if sb.path.ends_with?("/") || File.directory?(sb.path) diff --git a/src/invidious/routes/before_all.cr b/src/invidious/routes/before_all.cr index b75574b69..c71003d7e 100644 --- a/src/invidious/routes/before_all.cr +++ b/src/invidious/routes/before_all.cr @@ -80,13 +80,21 @@ module Invidious::Routes::BeforeAll "/companion/", }.any? { |r| env.request.resource.starts_with? r } + sid = nil if env.request.cookies.has_key? "SID" sid = env.request.cookies["SID"].value if sid.starts_with? "v1:" raise "Cannot use token as SID" end + end + # ArikTube: open or provision the session asserted by the reverse proxy + if asserted_email = Invidious::TrustedHeaderAuth.asserted_email(env) + sid = Invidious::TrustedHeaderAuth.ensure_session(env, sid, asserted_email) + end + + if sid if email = Database::SessionIDs.select_email(sid) user = Database::Users.select!(email: email) csrf_token = generate_response(sid, { diff --git a/src/invidious/trusted_header_auth.cr b/src/invidious/trusted_header_auth.cr new file mode 100644 index 000000000..8cc0b3b28 --- /dev/null +++ b/src/invidious/trusted_header_auth.cr @@ -0,0 +1,89 @@ +# Trusted-header authentication (ArikTube extension). +# +# An authenticating reverse proxy (Authelia behind Traefik) asserts the user +# name in a request header. The header is only honored when the TCP peer is +# listed in `trusted_proxies` — never based on X-Forwarded-For. The account +# is provisioned on first sight, so a browser with a proxy session never +# sees the Invidious login form. +# +# The reverse proxy MUST strip the configured header from client requests on +# every route that bypasses its authentication, or clients can impersonate +# users through those routes. +module Invidious::TrustedHeaderAuth + extend self + + # "::ffff:192.168.1.101" and "192.168.1.101" are the same peer + private def normalize_ip(address : String) : String + address.lchop("::ffff:") + end + + private def trusted_peer?(env) : Bool + remote = env.request.remote_address.as?(Socket::IPAddress) + return false unless remote + + peer = normalize_ip(remote.address) + CONFIG.trusted_header_auth.trusted_proxies.any? { |address| normalize_ip(address) == peer } + end + + # The user name asserted by the proxy, or nil when absent or untrusted. + def asserted_email(env) : String? + config = CONFIG.trusted_header_auth + return nil unless config.enabled + + # API clients (Yattee, bots) authenticate with tokens only + return nil if env.request.path.starts_with?("/api/") + + values = env.request.headers.get?(config.header) + return nil unless values + # A duplicated header is an attack indicator: reject the request + return nil if values.size != 1 + + email = values[0].strip.downcase.byte_slice(0, 254) + return nil if email.empty? + return nil unless trusted_peer?(env) + + email + end + + # Return a session id for `email`. Reuses `sid` when that session already + # belongs to the user; otherwise provisions account + session and sets the + # SID cookie, mirroring the manual login flow (routes/login.cr). + def ensure_session(env, sid : String?, email : String) : String + if sid + session_email = Invidious::Database::SessionIDs.select_email(sid) + return sid if session_email == email + # Identity switch: the cookie belongs to somebody else. Drop it. + Invidious::Database::SessionIDs.delete(sid: sid) if session_email + end + + if !Invidious::Database::Users.select(email: email) + provision_user(email) + end + + new_sid = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + Invidious::Database::SessionIDs.insert(new_sid, email, handle_conflicts: true) + + host = env.get("header_x-forwarded-host") + if alt = CONFIG.alternative_domains.index(host) + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.alternative_domains[alt], new_sid) + else + env.response.cookies["SID"] = Invidious::User::Cookies.sid(CONFIG.domain, new_sid) + end + + new_sid + end + + # Same steps as manual registration (routes/login.cr), with a random + # password nobody knows. The materialized view is required — without it + # the subscriptions feed raises. Both statements tolerate a concurrent + # provision of the same user (parallel first-page requests). + private def provision_user(email : String) + random_password = Base64.urlsafe_encode(Random::Secure.random_bytes(32)) + user, _ = create_user("", email, random_password) + + Invidious::Database::Users.insert(user, update_on_conflict: true) + + view_name = "subscriptions_#{sha256(user.email)}" + PG_DB.exec("CREATE MATERIALIZED VIEW IF NOT EXISTS #{view_name} AS #{MATERIALIZED_VIEW_SQL.call(user.email)}") + end +end diff --git a/src/invidious/views/template.ecr b/src/invidious/views/template.ecr index d93a4ea68..7504bcada 100644 --- a/src/invidious/views/template.ecr +++ b/src/invidious/views/template.ecr @@ -72,12 +72,18 @@ <% end %>