mirror of
https://github.com/iv-org/invidious.git
synced 2026-09-06 00:52:45 -05:00
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:
parent
cce26864ad
commit
8e5a9e079e
@ -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.
|
||||
##
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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, {
|
||||
|
||||
89
src/invidious/trusted_header_auth.cr
Normal file
89
src/invidious/trusted_header_auth.cr
Normal 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
|
||||
@ -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">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user