Add trusted-header authentication (Authelia SSO)

New config block trusted_header_auth. When enabled, before_all
resolves the session from the proxy-asserted header (default
Remote-User) instead of only the SID cookie:

- The header is honored only when the direct TCP peer is in
  trusted_proxies (literal IPs, IPv4-mapped IPv6 normalized).
  X-Forwarded-For is never consulted. Duplicated headers reject.
- /api/ is excluded: token clients (Yattee) are unaffected.
- Unknown users are provisioned like manual registration, with the
  subscriptions materialized view and a random bcrypt password.
  Both statements tolerate concurrent provisioning.
- A session cookie is set and reused; a cookie that belongs to a
  different user is dropped (identity-switch guard).
- Boot fails closed: enabled without valid trusted_proxies exits.
- Optional logout_url replaces the local sign-out form so logout
  ends the proxy session, not just the Invidious one.

The reverse proxy MUST strip the header on routes that bypass its
authentication (see config.example.yml warning).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
NeskireDK 2026-08-12 17:49:53 +02:00
parent cce26864ad
commit 8e5a9e079e
5 changed files with 164 additions and 5 deletions

View File

@ -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.
##

View File

@ -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)

View File

@ -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, {

View File

@ -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

View File

@ -72,12 +72,18 @@
</div>
<% end %>
<div class="pure-u-1-4">
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a class="pure-menu-heading" href="#">
<input style="all:unset" type="submit" value="<%= I18n.translate(locale, "Log out") %>">
<% if CONFIG.trusted_header_auth.enabled && (logout_url = CONFIG.trusted_header_auth.logout_url) %>
<a class="pure-menu-heading" href="<%= HTML.escape(logout_url) %>">
<%= I18n.translate(locale, "Log out") %>
</a>
</form>
<% else %>
<form action="/signout?referer=<%= env.get?("current_page") %>" method="post">
<input type="hidden" name="csrf_token" value="<%= HTML.escape(env.get?("csrf_token").try &.as(String) || "") %>">
<a class="pure-menu-heading" href="#">
<input style="all:unset" type="submit" value="<%= I18n.translate(locale, "Log out") %>">
</a>
</form>
<% end %>
</div>
<% else %>
<div class="pure-u-1-3">