This commit is contained in:
Mateusz Gruszczyński committed 2026-10-27 12:18:20 +01:00
1 parent 62b7574553
commit e364392659
47 files changed
+2164 -257

No files matched your search

+27 -1
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.34.14",
"version": "0.34.15",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust. The API also permits explicitly scoped GeneralApiBearer tokens on a narrow route allowlist, with RO/RW and source CIDR checks; API-token management and Prometheus-token management are browser-session-only."
},
"servers": [
@@ -439,6 +439,24 @@
"id"
]
},
"ForwardAuthConfig": {
"type": "object",
"description": "Per-Vhost Authelia or authentik verification. Disabled by default. All other settings are optional and use RustProxy defaults; when enabled, url must identify an internal HTTP(S) origin (or expected verifier path). Authelia additionally requires a public HTTPS login_url. External addresses retain normal safe upstream validation, with TLS verification always enabled for HTTPS.",
"additionalProperties": false,
"properties": {
"enabled": {"type": "boolean", "default": false},
"url": {"type": "string", "maxLength": 2048, "description": "Internal service/outpost origin or the provider's verifier endpoint"},
"login_url": {"type": "string", "maxLength": 2048, "description": "Required public HTTPS portal URL for Authelia; ignored for authentik"},
"allow_private": {"type": "boolean", "default": false},
"address_allowlist": {"type": "array", "maxItems": 32, "items": {"type": "string"}},
"tls_ca_pem": {"type": "string", "nullable": true},
"connect_timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 30, "default": 5},
"timeout_seconds": {"type": "integer", "minimum": 1, "maximum": 60, "default": 10},
"request_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Only cookie, authorization, user-agent, accept, accept-language and x-api-key are allowed"},
"response_headers": {"type": "array", "maxItems": 32, "items": {"type": "string", "maxLength": 128}, "description": "Allowed Remote-*, X-Auth-Request-* and X-Authentik-* identity headers; total injected identity data capped at 8 KiB"},
"redirect_forbidden": {"type": "boolean", "default": false, "description": "Authelia only: accept trusted portal Location on explicit 403 if provided"}
}
},
"Vhost": {
"type": "object",
"properties": {
@@ -534,6 +552,14 @@
"properties": {},
"additionalProperties": true
},
"authelia": {
"$ref": "#/components/schemas/ForwardAuthConfig",
"description": "Authelia ForwardAuth. Requires a public HTTPS login_url and /api/authz/forward-auth verifier. Disabled by default; mutually exclusive with basic_auth, tinyauth and authentik."
},
"authentik": {
"$ref": "#/components/schemas/ForwardAuthConfig",
"description": "authentik single-application forward auth. The URL is the internal outpost origin; RustProxy proxies its reserved path on the protected host. Disabled by default; mutually exclusive with basic_auth, tinyauth and authelia."
},
"cache": {
"type": "object",
"properties": {
+2
View File
@@ -41,6 +41,8 @@ fn status_payload(state: &App) -> Value {
"security_headers",
"http3",
"tinyauth",
"authelia_forward_auth",
"authentik_forward_auth",
"trusted_forwarding",
"named_listeners",
"stream_proxy",
+10
View File
@@ -500,6 +500,16 @@ main {
font-size: 12px;
}
.auth-provider-options {
display: flex;
flex-wrap: wrap;
gap: 7px 18px;
}
.auth-provider-options>.vh-check {
flex: 1 1 250px;
}
.auth-advanced {
border-top: 1px solid var(--line);
padding-top: 4px;
@@ -4,7 +4,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>502 - Tinyauth connection error - RustProxy</title>
<title>{{STATUS}} - {{PROVIDER}} {{ERROR}} - RustProxy</title>
<style>
body {
margin: 12vh auto;
@@ -29,8 +29,9 @@
<body>
<main>
<h1>502 - Tinyauth connection error</h1>
<p>Unable to connect to the Tinyauth authentication service.</p>
<h1>{{STATUS}} - {{PROVIDER}} {{ERROR}}</h1>
<p>{{MESSAGE}}</p>
<p>Access to the protected application is temporarily unavailable.</p>
</main>
</body>
-37
View File
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>504 - Tinyauth timeout - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>504 - Tinyauth timeout</h1>
<p>The Tinyauth authentication service did not respond in time.</p>
</main>
</body>
</html>
@@ -1,37 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>502 - Tinyauth verification error - RustProxy</title>
<style>
body {
margin: 12vh auto;
padding: 24px;
max-width: 620px;
background: #131615;
color: #e5e9e6;
font: 15px/1.6 system-ui, sans-serif
}
h1 {
font-size: 24px;
margin: 0 0 8px
}
p {
margin: 0;
color: #b9c3bc
}
</style>
</head>
<body>
<main>
<h1>502 - Tinyauth verification error</h1>
<p>The Tinyauth authentication service returned an invalid response.</p>
</main>
</body>
</html>
+57 -9
View File
@@ -33,6 +33,18 @@ function hostTagEditor(parent, hosts = [], onUpdate = () => { }) {
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
}
function readForwardProvider(id) {
const value = key => str(`${id}-${key}`);
return {
enabled: str("auth-mode") === id,
url: value("url"), login_url: id === "authelia" ? value("login") : "",
allow_private: yes(`${id}-private`), redirect_forbidden: yes(`${id}-redirect-forbidden`),
connect_timeout_seconds: num(`${id}-connect`), timeout_seconds: num(`${id}-timeout`),
request_headers: lines(value("request-headers")), response_headers: lines(value("response-headers")),
address_allowlist: lines(value("allowlist")), tls_ca_pem: nullable(`${id}-ca`),
};
}
function load(value = defaults(), migrationIds = [], show = true) {
draftEpoch++; stagedCertificates = []; uploading = false; backendHealthEditorControl = null;
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
@@ -332,19 +344,18 @@ function load(value = defaults(), migrationIds = [], show = true) {
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers, tlsPqc]) input.disabled = !customTlsPolicy.checked; };
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp"); note(g, "tlsPqcVhostHelp");
g = group("vhTinyauth"); const auth = value.tinyauth || {};
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : value.authelia?.enabled ? "authelia" : value.authentik?.enabled ? "authentik" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["authelia", "authAuthelia"], ["authentik", "authAuthentik"], ["basic", "authBasic"]] });
note(g, "authModeHelp");
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); g.append(taBody, basicBody);
taBody.append(text("h3", "authTinyauth"));
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
authEnable.closest("label").hidden = true;
const taOptions = el("div", undefined, "vh-wide auth-provider-options"); taPrimary.append(taOptions);
field(taOptions, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
field(taOptions, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
note(taBody, "taHelp");
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
@@ -356,14 +367,49 @@ function load(value = defaults(), migrationIds = [], show = true) {
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
const forwardEditors = {};
for (const id of ["authelia", "authentik"]) {
const current = value[id] || {}, title = id === "authelia" ? "authAuthelia" : "authAuthentik";
const body = el("section", undefined, "vh-wide auth-subcard"); g.append(body);
body.append(text("h3", title));
const primary = el("div", undefined, "vh-grid"); body.append(primary);
const url = field(primary, `${id}-url`, "forwardAuthUrl", current.url || "", "url", { maxLength: 2048 });
url.placeholder = id === "authelia" ? "http://authelia:9091" : "http://authentik:9000";
let login = null;
if (id === "authelia") {
login = field(primary, `${id}-login`, "forwardAuthLogin", current.login_url || "", "url", { maxLength: 2048 });
login.placeholder = "https://auth.example.com";
}
const options = el("div", undefined, "vh-wide auth-provider-options"); primary.append(options);
field(options, `${id}-private`, "vhPrivate", current.allow_private, "checkbox");
field(options, `${id}-redirect-forbidden`, "taRedirectForbidden", current.redirect_forbidden === true, "checkbox");
note(body, id === "authelia" ? "autheliaHelp" : "authentikHelp");
const advanced = el("details", undefined, "vh-wide auth-advanced"); advanced.append(text("summary", "forwardAuthAdvanced"));
const details = el("div", undefined, "vh-grid"); advanced.append(details); body.append(advanced);
field(details, `${id}-connect`, "taConnect", current.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
field(details, `${id}-timeout`, "taTimeout", current.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
field(details, `${id}-request-headers`, "taRequestHeaders", (current.request_headers || ["cookie", "authorization", "user-agent", "accept", "accept-language"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
const autheliaHeaders = ["remote-user", "remote-groups", "remote-name", "remote-email"];
const authentikHeaders = ["x-authentik-username", "x-authentik-email", "x-authentik-name", "x-authentik-groups", "x-authentik-uid", "x-authentik-entitlements"];
field(details, `${id}-response-headers`, "taResponseHeaders", (current.response_headers || [...autheliaHeaders, ...authentikHeaders]).join("\n"), "textarea", { rows: 4, maxLength: 8192 });
field(details, `${id}-allowlist`, "vhAddressAllowlist", (current.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
field(details, `${id}-ca`, "vhCa", current.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
forwardEditors[id] = { body, url, login };
}
basicBody.append(text("h3", "authBasic"));
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
const syncAuth = () => {
authEnable.checked = authMode.value === "tinyauth";
taBody.hidden = !authEnable.checked;
const tinyauthSelected = authMode.value === "tinyauth";
taBody.hidden = !tinyauthSelected;
basicBody.hidden = authMode.value !== "basic";
authUrl.required = loginUrl.required = authEnable.checked;
authUrl.required = loginUrl.required = tinyauthSelected;
for (const [id, editor] of Object.entries(forwardEditors)) {
editor.body.hidden = id !== authMode.value;
editor.url.required = id === authMode.value;
if (editor.login) editor.login.required = id === authMode.value;
}
};
authMode.addEventListener("change", syncAuth); syncAuth();
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
@@ -508,7 +554,9 @@ function collect() {
content_type_policy: { enabled: yes("content-type-block-enabled"), blocked: lines(str("content-type-block-list")).map(value => value.toLowerCase()) },
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
tinyauth: { enabled: str("auth-mode") === "tinyauth", url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") },
authelia: readForwardProvider("authelia"), authentik: readForwardProvider("authentik"),
backends: members, routes: paths, acl, headers,
load_balancing: {
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive"), pool_recycle_requests: num("pool-recycle-requests"), pool_recycle_seconds: num("pool-recycle-seconds"), http1_max_buffer_bytes: num("http1-max-buffer") },
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
+2 -3
View File
@@ -1,5 +1,3 @@
// Shared, anchored Vhost popover for backend quick-view and upstream HTTP
// protocol observations. Both consume existing live status without extra probes.
let activeVhostBackendPopup = null;
function closeVhostBackendPopup(returnFocus = false) {
const current = activeVhostBackendPopup;
@@ -390,6 +388,8 @@ function render() {
const policy = el("div", undefined, "vhost-policy-pills");
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
if (v.authelia?.enabled) policy.append(el("small", "Authelia", "pill"));
if (v.authentik?.enabled) policy.append(el("small", "authentik", "pill"));
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
@@ -573,7 +573,6 @@ form.addEventListener("submit", async event => {
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
const oldVhost = (envelope.config.vhosts || []).find(v => v.id === editing);
// A previous acceptance must not silently authorize new hostnames or a different certificate.
const certificateScope = item => JSON.stringify({
certificate: item?.certificate_id || null,
hosts: [...(item?.frontend?.hosts || [])].map(v => v.toLowerCase().replace(/\.$/, "")).sort(),
+160
View File
@@ -0,0 +1,160 @@
use axum::response::{IntoResponse, Response};
use http::{header, HeaderValue, StatusCode};
use super::AuthenticationResponse;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum GatewayFailure {
Connection,
Timeout,
Verification,
}
pub fn deny(status: StatusCode) -> Response {
let body = format!(
"{} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Error")
);
let mut response = (status, body).into_response();
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response.extensions_mut().insert(AuthenticationResponse);
response
}
fn escape_html(raw: &str) -> String {
raw.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&#39;")
.replace('{', "&#123;")
.replace('}', "&#125;")
}
pub fn gateway_failure(provider_name: &str, kind: GatewayFailure) -> Response {
let (status, label, message) = match kind {
GatewayFailure::Connection => (
StatusCode::BAD_GATEWAY,
"connection error",
"Unable to connect to the",
),
GatewayFailure::Timeout => (StatusCode::GATEWAY_TIMEOUT, "timeout", "The"),
GatewayFailure::Verification => (StatusCode::BAD_GATEWAY, "verification error", "The"),
};
let name = escape_html(provider_name);
let explanation = match kind {
GatewayFailure::Connection => format!("{message} {name} authentication service."),
GatewayFailure::Timeout => {
format!("{message} {name} authentication service did not respond in time.")
}
GatewayFailure::Verification => {
format!("{message} {name} authentication service returned an invalid response.")
}
};
let html = include_str!("../admin/web/error_auth_provider.html")
.replace("{{STATUS}}", &status.as_u16().to_string())
.replace("{{PROVIDER}}", &name)
.replace("{{ERROR}}", label)
.replace("{{MESSAGE}}", &explanation);
let mut response = (status, html).into_response();
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response.extensions_mut().insert(AuthenticationResponse);
response
}
#[cfg(test)]
mod tests {
use super::*;
use http_body_util::BodyExt;
#[tokio::test]
async fn one_template_covers_every_provider_and_gateway_failure() {
for name in ["Tinyauth", "Authelia", "authentik", "Future Provider"] {
for (kind, status, description) in [
(
GatewayFailure::Connection,
StatusCode::BAD_GATEWAY,
"connection error",
),
(
GatewayFailure::Verification,
StatusCode::BAD_GATEWAY,
"verification error",
),
(
GatewayFailure::Timeout,
StatusCode::GATEWAY_TIMEOUT,
"timeout",
),
] {
let response = gateway_failure(name, kind);
assert_eq!(response.status(), status);
assert_eq!(
response.headers()[header::CONTENT_TYPE],
"text/html; charset=utf-8"
);
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert!(response
.extensions()
.get::<AuthenticationResponse>()
.is_some());
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = std::str::from_utf8(&body).unwrap();
assert!(html.contains(&format!("{} - {name} {description}", status.as_u16())));
assert!(
!html.contains("{{")
&& !html.contains("<script")
&& !html.contains("127.0.0.1")
);
}
}
}
#[tokio::test]
async fn provider_name_cannot_inject_html() {
let response = gateway_failure("<script>alert(1)</script>", GatewayFailure::Connection);
let html = String::from_utf8(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(html.contains("&lt;script&gt;"));
assert!(!html.contains("<script>"));
}
#[test]
fn denied_responses_are_marked_and_not_cached() {
let response = deny(StatusCode::UNAUTHORIZED);
assert!(response
.extensions()
.get::<AuthenticationResponse>()
.is_some());
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
}
}
+263
View File
@@ -0,0 +1,263 @@
use crate::{authelia, authentik, config::ValidationPolicy, forwarding, tinyauth};
use anyhow::{ensure, Result};
use axum::{body::Body, response::Response};
use http::{header, HeaderMap, HeaderValue, Method, Request};
use std::{
future::Future,
net::SocketAddr,
pin::Pin,
sync::{Arc, Mutex},
};
use tokio_util::sync::CancellationToken;
pub type Authorization<'a> =
Pin<Box<dyn Future<Output = std::result::Result<Identity, Response>> + Send + 'a>>;
pub type Route<'a> = Pin<Box<dyn Future<Output = Response> + Send + 'a>>;
mod errors;
pub use errors::{deny, gateway_failure, GatewayFailure};
#[derive(Debug, Default)]
pub struct Identity {
pub(crate) headers: HeaderMap,
}
impl Identity {
pub fn apply(&self, headers: &mut HeaderMap) {
for name in self.headers.keys() {
headers.remove(name);
}
for (name, value) in &self.headers {
headers.append(name.clone(), value.clone());
}
}
}
pub trait Provider: Send + Sync + std::fmt::Debug {
fn id(&self) -> &'static str;
fn strip_identity(&self, headers: &mut HeaderMap);
fn authorize<'a>(
&'a self,
incoming: &'a HeaderMap,
method: &'a Method,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
cookies: &'a CookieSink,
) -> Authorization<'a>;
fn reserved_path(&self, _path: &str) -> bool {
false
}
fn serve_reserved<'a>(
&'a self,
_request: Request<Body>,
_context: &'a forwarding::Context,
_shutdown: &'a CancellationToken,
) -> Option<Route<'a>> {
None
}
}
#[derive(Debug)]
pub struct Compiled(Box<dyn Provider>);
impl Compiled {
fn new<T: Provider + 'static>(provider: T) -> Self {
Self(Box::new(provider))
}
pub fn id(&self) -> &'static str {
self.0.id()
}
pub fn strip_identity(&self, headers: &mut HeaderMap) {
self.0.strip_identity(headers);
}
pub fn reserved_path(&self, path: &str) -> bool {
self.0.reserved_path(path)
}
pub fn serve_reserved<'a>(
&'a self,
request: Request<Body>,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
) -> Option<Route<'a>> {
self.0.serve_reserved(request, context, shutdown)
}
pub async fn authorize(
&self,
incoming: &HeaderMap,
method: &Method,
context: &forwarding::Context,
shutdown: &CancellationToken,
cookies: &CookieSink,
) -> std::result::Result<Identity, Response> {
self.0
.authorize(incoming, method, context, shutdown, cookies)
.await
}
}
pub struct Selection<'a> {
pub basic_enabled: bool,
pub tinyauth: &'a tinyauth::Settings,
pub authelia: &'a authelia::Settings,
pub authentik: &'a authentik::Settings,
}
impl Selection<'_> {
pub fn validate(&self) -> Result<()> {
self.tinyauth.validate()?;
self.authelia.validate()?;
self.authentik.validate()?;
let active = [
self.basic_enabled,
self.tinyauth.enabled,
self.authelia.enabled,
self.authentik.enabled,
];
ensure!(
active.iter().filter(|enabled| **enabled).count() <= 1,
"choose only one authentication provider (Basic, Tinyauth, Authelia or authentik)"
);
Ok(())
}
pub fn compile(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
self.validate()?;
if self.tinyauth.enabled {
return Ok(self.tinyauth.compile(policy, bindings)?.map(Compiled::new));
}
if self.authelia.enabled {
return Ok(self.authelia.compile(policy, bindings)?.map(Compiled::new));
}
if self.authentik.enabled {
return Ok(self.authentik.compile(policy, bindings)?.map(Compiled::new));
}
Ok(None)
}
}
#[derive(Clone)]
pub struct AuthenticationResponse;
#[derive(Clone, Default)]
pub struct CookieSink(Arc<Mutex<Vec<HeaderValue>>>);
impl CookieSink {
pub(crate) fn collect(&self, headers: &HeaderMap) -> bool {
let cookies: Vec<_> = headers
.get_all(header::SET_COOKIE)
.iter()
.cloned()
.collect();
let bytes: usize = cookies.iter().map(|value| value.as_bytes().len()).sum();
if cookies.len() > 8 || bytes > 16384 {
return false;
}
if !cookies.is_empty() {
let mut store = self.0.lock().unwrap_or_else(|poison| poison.into_inner());
if store.len() + cookies.len() > 8
|| store.iter().map(|c| c.as_bytes().len()).sum::<usize>() + bytes > 16384
{
return false;
}
store.extend(cookies);
}
true
}
pub fn apply(&self, response: &mut Response) {
let mut cookies = self.0.lock().unwrap_or_else(|poison| poison.into_inner());
for cookie in cookies.drain(..) {
response.headers_mut().append(header::SET_COOKIE, cookie);
}
}
}
#[cfg(test)]
pub(crate) mod testing;
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn legacy_vhost_keys_are_still_accepted_and_no_provider_is_enabled_by_default() {
let host: crate::vhost::Vhost = serde_json::from_value(serde_json::json!({
"id": "test", "frontend": {"hosts": ["app.example.com"], "listener_ids": ["https"]}
}))
.unwrap();
let selection = Selection {
basic_enabled: host.basic_auth.enabled,
tinyauth: &host.tinyauth,
authelia: &host.authelia,
authentik: &host.authentik,
};
assert!(selection.validate().is_ok());
}
#[test]
fn rejects_multiple_active_providers_before_compilation() {
let mut tinyauth = tinyauth::Settings::default();
let mut authelia = authelia::Settings::default();
let authentik = authentik::Settings::default();
tinyauth.enabled = true;
authelia.enabled = true;
let selection = Selection {
basic_enabled: false,
tinyauth: &tinyauth,
authelia: &authelia,
authentik: &authentik,
};
assert!(selection.validate().is_err());
}
#[derive(Debug)]
struct FutureProvider;
impl Provider for FutureProvider {
fn id(&self) -> &'static str {
"future"
}
fn strip_identity(&self, _headers: &mut HeaderMap) {}
fn authorize<'a>(
&'a self,
_incoming: &'a HeaderMap,
_method: &'a Method,
_context: &'a forwarding::Context,
_shutdown: &'a CancellationToken,
_cookies: &'a CookieSink,
) -> Authorization<'a> {
Box::pin(async { Err(deny(http::StatusCode::UNAUTHORIZED)) })
}
}
#[tokio::test]
async fn future_providers_can_use_runtime_interface_without_proxy_branches() {
let provider = Compiled::new(FutureProvider);
assert_eq!(provider.id(), "future");
assert!(!provider.reserved_path("/"));
let headers = HeaderMap::new();
let response = provider
.authorize(
&headers,
&Method::GET,
&testing::context(&headers, "app.example.com"),
&CancellationToken::new(),
&CookieSink::default(),
)
.await
.unwrap_err();
assert_eq!(response.status(), http::StatusCode::UNAUTHORIZED);
assert!(response
.extensions()
.get::<AuthenticationResponse>()
.is_some());
}
#[test]
fn cookie_sink_is_request_scoped_and_bounded() {
let first = CookieSink::default();
let second = CookieSink::default();
let mut headers = HeaderMap::new();
headers.append(header::SET_COOKIE, "session=ok; Path=/".parse().unwrap());
assert!(first.collect(&headers));
let mut resp = Response::new(Body::empty());
second.apply(&mut resp);
assert!(resp.headers().get(header::SET_COOKIE).is_none());
first.apply(&mut resp);
assert_eq!(resp.headers()[header::SET_COOKIE], "session=ok; Path=/");
assert_eq!(first.0.lock().unwrap().len(), 0);
}
}
+55
View File
@@ -0,0 +1,55 @@
use crate::{config::ValidationPolicy, forwarding};
use http::HeaderMap;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
pub(crate) fn policy() -> ValidationPolicy {
ValidationPolicy {
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
admin_addr: "127.0.0.1:9090".parse().unwrap(),
tls_addr: None,
cert_dir: None,
data_dir: None,
}
}
pub(crate) fn context(incoming: &HeaderMap, host: &str) -> forwarding::Context {
forwarding::Settings::default()
.compile()
.unwrap()
.context(
incoming,
"192.0.2.20:34567".parse().unwrap(),
"10.0.0.1:443".parse().unwrap(),
true,
&host.parse().unwrap(),
&"/private?redirect=%2Fhome".parse().unwrap(),
)
.unwrap()
}
pub(crate) async fn spawn_mock(
status: &str,
response_headers: &str,
) -> (String, tokio::task::JoinHandle<String>) {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let address = listener.local_addr().unwrap();
let response = format!(
"HTTP/1.1 {status}\r\nContent-Length: 0\r\nConnection: close\r\n{response_headers}\r\n"
);
let task = tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut bytes = Vec::new();
let mut chunk = [0u8; 4096];
loop {
let n = stream.read(&mut chunk).await.unwrap();
if n == 0 {
break;
}
bytes.extend_from_slice(&chunk[..n]);
if bytes.windows(4).any(|value| value == b"\r\n\r\n") {
break;
}
}
stream.write_all(response.as_bytes()).await.unwrap();
String::from_utf8_lossy(&bytes).into_owned()
});
(format!("http://{address}"), task)
}
+127
View File
@@ -0,0 +1,127 @@
use crate::{
auth_provider::{self, CookieSink},
config::ValidationPolicy,
forward_auth, forwarding,
};
use anyhow::{ensure, Result};
use axum::response::Response;
use http::{header, HeaderMap, Method, StatusCode};
use serde::{Deserialize, Serialize};
use std::{
net::SocketAddr,
ops::{Deref, DerefMut},
};
use tokio_util::sync::CancellationToken;
use url::Url;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Settings(forward_auth::Settings);
impl Deref for Settings {
type Target = forward_auth::Settings;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Settings {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
self.0.validate::<Adapter>()
}
pub fn compile(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
Ok(self.0.compile::<Adapter>(policy, bindings)?.map(Compiled))
}
}
#[derive(Debug)]
pub struct Compiled(forward_auth::Compiled<Adapter>);
#[derive(Debug)]
pub struct Adapter;
impl forward_auth::Adapter for Adapter {
fn label() -> &'static str {
"Authelia"
}
fn verification_path() -> &'static str {
"/api/authz/forward-auth"
}
fn portal(settings: &forward_auth::Settings) -> Result<Option<Url>> {
let portal = forward_auth::http_url(&settings.login_url, "Authelia login")?;
ensure!(
forward_auth::is_portal_https(&portal),
"public Authelia login must use HTTPS"
);
Ok(Some(portal))
}
fn denied(
status: StatusCode,
response_headers: &HeaderMap,
_original_url: &str,
portal: Option<&Url>,
redirect_forbidden: bool,
) -> std::result::Result<Response, Response> {
if matches!(
status,
StatusCode::FOUND | StatusCode::SEE_OTHER | StatusCode::UNAUTHORIZED
) || (status == StatusCode::FORBIDDEN && redirect_forbidden)
{
let location = portal.and_then(|url| {
forward_auth::login_redirect(response_headers.get(header::LOCATION), url)
});
if let Some(location) = location {
let mut response = forward_auth::fail(if status == StatusCode::SEE_OTHER {
StatusCode::SEE_OTHER
} else {
StatusCode::FOUND
});
response.headers_mut().insert(header::LOCATION, location);
Ok(response)
} else if matches!(status, StatusCode::FOUND | StatusCode::SEE_OTHER) {
Err(forward_auth::invalid(
"Authelia",
"Authelia returned an untrusted login redirect",
))
} else {
Ok(forward_auth::fail(status))
}
} else if status == StatusCode::FORBIDDEN {
Ok(forward_auth::fail(status))
} else {
Err(forward_auth::invalid(
"Authelia",
"Authelia returned an unexpected verifier status",
))
}
}
}
impl auth_provider::Provider for Compiled {
fn id(&self) -> &'static str {
"authelia"
}
fn strip_identity(&self, headers: &mut HeaderMap) {
self.0.strip_identity(headers);
}
fn authorize<'a>(
&'a self,
incoming: &'a HeaderMap,
method: &'a Method,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
cookies: &'a CookieSink,
) -> auth_provider::Authorization<'a> {
Box::pin(
self.0
.authorize(incoming, method, context, shutdown, cookies),
)
}
}
#[cfg(test)]
include!("tests.rs");
+85
View File
@@ -0,0 +1,85 @@
mod tests {
use super::*;
use crate::auth_provider::{Provider, testing::{context, policy, spawn_mock}};
use http_body_util::BodyExt;
fn settings(url: &str) -> Settings {
let mut settings = Settings::default();
settings.enabled = true;
settings.url = url.into();
settings.login_url = "https://auth.example.com".into();
settings.allow_private = true;
settings
}
fn compile(url: &str) -> Compiled { settings(url).compile(&policy(), &[]).unwrap().unwrap() }
#[test]
fn legacy_json_shape_and_provider_identity_are_preserved() {
let parsed: Settings = serde_json::from_str(r#"{"enabled":true,"url":"http://127.0.0.1:9300","login_url":"https://auth.example.com","allow_private":true}"#).unwrap();
assert!(parsed.enabled);
let serialized = serde_json::to_value(&parsed).unwrap();
assert_eq!(serialized["url"], "http://127.0.0.1:9300");
assert!(serialized.get("0").is_none());
assert_eq!(compile("http://127.0.0.1:9300").id(), "authelia");
}
#[test]
fn invalid_portal_and_cross_origin_redirects_are_rejected() {
let mut s = settings("http://127.0.0.1:9300");
s.login_url = "http://auth.example.com".into();
assert!(s.validate().is_err());
let portal = Url::parse("https://auth.example.com/").unwrap();
for bad in ["https://evil.example/", "//evil.example", "https://auth.example.com@evil.example/", "javascript:alert(1)", "https://auth.example.com/#frag"] {
assert!(forward_auth::login_redirect(Some(&bad.parse().unwrap()), &portal).is_none(), "{bad}");
}
assert_eq!(forward_auth::login_redirect(Some(&"/login?rd=1".parse().unwrap()), &portal).unwrap(), "https://auth.example.com/login?rd=1");
}
#[tokio::test]
async fn checks_forward_auth_endpoint_and_returns_only_verified_identity() {
let (url, server) = spawn_mock("200 OK", "Remote-User: trusted\r\nRemote-Email: trusted@example.com\r\n").await;
let auth = compile(&url);
let mut incoming = HeaderMap::new();
incoming.insert("remote-user", "forged".parse().unwrap());
let identity = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap();
let request = server.await.unwrap().to_ascii_lowercase();
assert!(request.contains("get /api/authz/forward-auth"));
assert!(request.contains("x-forwarded-host: app.example.com"));
assert!(request.contains("x-forwarded-uri: /private?redirect=%2fhome"));
assert!(!request.contains("remote-user: forged"));
let mut headers = HeaderMap::new();
identity.apply(&mut headers);
assert_eq!(headers["remote-user"], "trusted");
}
#[tokio::test]
async fn redirects_only_to_trusted_portal_and_denies_untrusted_redirects() {
let (url, server) = spawn_mock("302 Found", "Location: https://auth.example.com/?rd=home\r\n").await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::FOUND);
assert!(response.headers()[header::LOCATION].to_str().unwrap().starts_with("https://auth.example.com/"));
server.await.unwrap();
let (url, server) = spawn_mock("302 Found", "Location: https://evil.example/\r\n").await;
let auth = compile(&url);
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
server.await.unwrap();
}
#[tokio::test]
async fn unreachable_provider_fails_closed_with_no_origin_details() {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let auth = compile(&url);
let incoming = HeaderMap::new();
let response = auth.authorize(&incoming, &Method::GET, &context(&incoming, "app.example.com"),
&CancellationToken::new(), &CookieSink::default()).await.unwrap_err();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
assert!(response.extensions().get::<auth_provider::AuthenticationResponse>().is_some());
let body = response.into_body().collect().await.unwrap().to_bytes();
assert!(!body.windows(url.len()).any(|chunk| chunk == url.as_bytes()));
}
}
+221
View File
@@ -0,0 +1,221 @@
use crate::{
auth_provider::{self, CookieSink},
config::ValidationPolicy,
forward_auth, forwarding,
};
use anyhow::Result;
use axum::{body::Body, response::Response};
use http::{header, HeaderMap, HeaderValue, Method, Request, StatusCode, Uri};
use serde::{Deserialize, Serialize};
use std::{
net::SocketAddr,
ops::{Deref, DerefMut},
};
use tokio_util::sync::CancellationToken;
use url::Url;
const OUTPOST_PREFIX: &str = "/outpost.goauthentik.io";
pub fn is_outpost_path(path: &str) -> bool {
path == OUTPOST_PREFIX || path.starts_with("/outpost.goauthentik.io/")
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Settings(forward_auth::Settings);
impl Deref for Settings {
type Target = forward_auth::Settings;
fn deref(&self) -> &Self::Target {
&self.0
}
}
impl DerefMut for Settings {
fn deref_mut(&mut self) -> &mut Self::Target {
&mut self.0
}
}
impl Settings {
pub fn validate(&self) -> Result<()> {
self.0.validate::<Adapter>()
}
pub fn compile(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled>> {
Ok(self.0.compile::<Adapter>(policy, bindings)?.map(Compiled))
}
}
#[derive(Debug)]
pub struct Compiled(forward_auth::Compiled<Adapter>);
#[derive(Debug)]
pub struct Adapter;
impl forward_auth::Adapter for Adapter {
fn label() -> &'static str {
"authentik"
}
fn verification_path() -> &'static str {
"/outpost.goauthentik.io/auth/nginx"
}
fn denied(
status: StatusCode,
_response_headers: &HeaderMap,
original_url: &str,
_portal: Option<&Url>,
_redirect_forbidden: bool,
) -> std::result::Result<Response, Response> {
if status == StatusCode::UNAUTHORIZED {
let mut location = Url::parse(original_url).map_err(|_| {
forward_auth::invalid("authentik", "invalid original URL for authentik")
})?;
location.set_path("/outpost.goauthentik.io/start");
location.set_query(None);
location.query_pairs_mut().append_pair("rd", original_url);
let mut response = forward_auth::fail(StatusCode::FOUND);
response.headers_mut().insert(
header::LOCATION,
HeaderValue::from_str(location.as_str()).map_err(|_| {
forward_auth::invalid("authentik", "invalid authentik redirect")
})?,
);
Ok(response)
} else if status == StatusCode::FORBIDDEN {
Ok(forward_auth::fail(status))
} else {
Err(forward_auth::invalid(
"authentik",
"authentik returned an unexpected verifier status",
))
}
}
}
impl auth_provider::Provider for Compiled {
fn id(&self) -> &'static str {
"authentik"
}
fn strip_identity(&self, headers: &mut HeaderMap) {
self.0.strip_identity(headers);
}
fn authorize<'a>(
&'a self,
incoming: &'a HeaderMap,
method: &'a Method,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
cookies: &'a CookieSink,
) -> auth_provider::Authorization<'a> {
Box::pin(
self.0
.authorize(incoming, method, context, shutdown, cookies),
)
}
fn reserved_path(&self, path: &str) -> bool {
is_outpost_path(path)
}
fn serve_reserved<'a>(
&'a self,
request: Request<Body>,
context: &'a forwarding::Context,
shutdown: &'a CancellationToken,
) -> Option<auth_provider::Route<'a>> {
if !is_outpost_path(request.uri().path()) {
return None;
}
Some(Box::pin(self.proxy_outpost(request, context, shutdown)))
}
}
impl Compiled {
async fn proxy_outpost(
&self,
incoming: Request<Body>,
context: &forwarding::Context,
shutdown: &CancellationToken,
) -> Response {
if !is_outpost_path(incoming.uri().path()) {
return forward_auth::fail(StatusCode::NOT_FOUND);
}
if incoming.method() != Method::GET
&& incoming.method() != Method::HEAD
&& incoming.method() != Method::POST
{
return forward_auth::fail(StatusCode::METHOD_NOT_ALLOWED);
}
let (parts, body) = incoming.into_parts();
let suffix = parts
.uri
.path_and_query()
.map(|value| value.as_str())
.unwrap_or(OUTPOST_PREFIX);
let uri: Uri = match format!("{}{}", self.0.backend.request_origin, suffix).parse() {
Ok(uri) => uri,
Err(_) => return forward_auth::fail(StatusCode::BAD_REQUEST),
};
let mut outbound = match Request::builder()
.method(parts.method.clone())
.uri(uri)
.body(body)
{
Ok(request) => request,
Err(_) => return forward_auth::fail(StatusCode::BAD_REQUEST),
};
*outbound.headers_mut() = parts.headers;
crate::proxy::headers::strip_hop_by_hop(outbound.headers_mut());
crate::proxy::headers::strip_admin_cookie(outbound.headers_mut());
self.0.strip_identity(outbound.headers_mut());
if forward_auth::auth_context(outbound.headers_mut(), &parts.method, context).is_err() {
return forward_auth::fail(StatusCode::BAD_REQUEST);
}
let original_host = match Url::parse(&context.original_url) {
Ok(url) => url,
Err(_) => return forward_auth::fail(StatusCode::BAD_REQUEST),
};
let host = match original_host.port() {
Some(port) => format!("{}:{port}", original_host.host_str().unwrap_or("")),
None => original_host.host_str().unwrap_or("").to_owned(),
};
if host.is_empty() {
return forward_auth::fail(StatusCode::BAD_REQUEST);
}
if let Ok(host) = host.parse::<HeaderValue>() {
outbound.headers_mut().insert(header::HOST, host);
} else {
return forward_auth::fail(StatusCode::BAD_REQUEST);
}
let checked = tokio::select! {
_ = shutdown.cancelled() => return forward_auth::fail(StatusCode::SERVICE_UNAVAILABLE),
result = tokio::time::timeout(self.0.timeout, self.0.backend.client.request(outbound)) => result,
};
let response = match checked {
Ok(Ok(response)) => response,
Ok(Err(error)) => {
tracing::warn!(%error, "authentik outpost connection failed");
return auth_provider::gateway_failure(
"authentik",
auth_provider::GatewayFailure::Connection,
);
}
Err(_) => {
return auth_provider::gateway_failure(
"authentik",
auth_provider::GatewayFailure::Timeout,
)
}
};
let (parts, body) = response.into_parts();
let mut response = Response::new(Body::new(body));
*response.status_mut() = parts.status;
*response.headers_mut() = parts.headers;
crate::proxy::headers::strip_hop_by_hop(response.headers_mut());
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static("private, no-store"),
);
response
.extensions_mut()
.insert(auth_provider::AuthenticationResponse);
response
}
}
#[cfg(test)]
include!("tests.rs");
+176
View File
@@ -0,0 +1,176 @@
mod tests {
use super::*;
use crate::auth_provider::{
testing::{context, policy, spawn_mock},
Provider,
};
fn settings(url: &str) -> Settings {
let mut settings = Settings::default();
settings.enabled = true;
settings.url = url.into();
settings.allow_private = true;
settings
}
fn compile(url: &str) -> Compiled {
settings(url).compile(&policy(), &[]).unwrap().unwrap()
}
#[test]
fn legacy_json_and_provider_path_contract_are_preserved() {
let parsed: Settings = serde_json::from_str(
r#"{"enabled":true,"url":"http://127.0.0.1:9300","allow_private":true}"#,
)
.unwrap();
assert_eq!(
serde_json::to_value(&parsed).unwrap()["url"],
"http://127.0.0.1:9300"
);
let auth = compile("http://127.0.0.1:9300");
assert_eq!(auth.id(), "authentik");
assert!(auth.reserved_path("/outpost.goauthentik.io"));
assert!(auth.reserved_path("/outpost.goauthentik.io/start"));
assert!(!auth.reserved_path("/outpost.goauthentik.io-evil"));
assert!(!auth.reserved_path("/other/outpost.goauthentik.io"));
}
#[tokio::test]
async fn unauthorized_redirect_uses_application_domain_not_portal_domain() {
let (url, server) = spawn_mock(
"401 Unauthorized",
"Set-Cookie: authentik_session=abc; Secure; Path=/; HttpOnly\r\n",
)
.await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let response = auth
.authorize(
&incoming,
&Method::POST,
&context(&incoming, "app.other-domain.example"),
&CancellationToken::new(),
&CookieSink::default(),
)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::FOUND);
let location = Url::parse(response.headers()[header::LOCATION].to_str().unwrap()).unwrap();
assert_eq!(
location.origin().ascii_serialization(),
"https://app.other-domain.example"
);
assert_eq!(location.path(), "/outpost.goauthentik.io/start");
assert_eq!(
location.query_pairs().find(|(k, _)| k == "rd").unwrap().1,
"https://app.other-domain.example/private?redirect=%2Fhome"
);
assert_eq!(
response.headers()[header::SET_COOKIE],
"authentik_session=abc; Secure; Path=/; HttpOnly"
);
server.await.unwrap();
}
#[tokio::test]
async fn forbidden_response_never_authorizes_backend() {
let (url, server) = spawn_mock("403 Forbidden", "").await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let response = auth
.authorize(
&incoming,
&Method::GET,
&context(&incoming, "app.example.com"),
&CancellationToken::new(),
&CookieSink::default(),
)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::FORBIDDEN);
server.await.unwrap();
}
#[tokio::test]
async fn verified_headers_and_refresh_cookie_are_forwarded() {
let (url, server) = spawn_mock("200 OK", "X-Authentik-Username: allowed\r\nSet-Cookie: authentik_session=renew; Path=/; HttpOnly\r\n").await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let cookies = CookieSink::default();
let identity = auth
.authorize(
&incoming,
&Method::GET,
&context(&incoming, "app.example.com"),
&CancellationToken::new(),
&cookies,
)
.await
.unwrap();
let mut headers = HeaderMap::new();
identity.apply(&mut headers);
assert_eq!(headers["x-authentik-username"], "allowed");
let mut response = Response::new(Body::empty());
cookies.apply(&mut response);
assert_eq!(
response.headers()[header::SET_COOKIE],
"authentik_session=renew; Path=/; HttpOnly"
);
server.await.unwrap();
}
#[tokio::test]
async fn outpost_is_proxied_only_on_reserved_path_with_trusted_host() {
let (url, server) = spawn_mock("302 Found", "Location: https://auth.example.com/if/flow/default/\r\nSet-Cookie: authentik=ok; Path=/\r\n").await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let request = Request::builder()
.uri("/outpost.goauthentik.io/start?rd=https%3A%2F%2Fapp.example.com")
.header("X-Forwarded-Host", "spoofed.example")
.header("X-Authentik-Username", "forged")
.body(Body::empty())
.unwrap();
let response = auth
.serve_reserved(
request,
&context(&incoming, "app.example.com"),
&CancellationToken::new(),
)
.unwrap()
.await;
assert_eq!(response.status(), StatusCode::FOUND);
assert_eq!(
response.headers()[header::SET_COOKIE],
"authentik=ok; Path=/"
);
assert_eq!(
response.headers()[header::CACHE_CONTROL],
"private, no-store"
);
let request = server.await.unwrap().to_ascii_lowercase();
assert!(request.starts_with("get /outpost.goauthentik.io/start?rd="));
assert!(request.contains("host: app.example.com"));
assert!(request.contains("x-forwarded-host: app.example.com"));
assert!(!request.contains("spoofed.example"));
assert!(!request.contains("x-authentik-username: forged"));
}
#[tokio::test]
async fn auth_check_uses_provider_specific_endpoint() {
let (url, server) = spawn_mock("200 OK", "X-Authentik-Username: admin\r\n").await;
let auth = compile(&url);
let incoming = HeaderMap::new();
let identity = auth
.authorize(
&incoming,
&Method::GET,
&context(&incoming, "app.example.com"),
&CancellationToken::new(),
&CookieSink::default(),
)
.await
.unwrap();
let mut headers = HeaderMap::new();
identity.apply(&mut headers);
assert_eq!(headers["x-authentik-username"], "admin");
assert!(server
.await
.unwrap()
.to_ascii_lowercase()
.contains("get /outpost.goauthentik.io/auth/nginx"));
}
}
+1 -1
View File
@@ -154,7 +154,7 @@ fn denied(status: StatusCode, challenge: Option<&HeaderValue>) -> Response {
}
response
.extensions_mut()
.insert(crate::tinyauth::AuthenticationFailure);
.insert(crate::auth_provider::AuthenticationResponse);
response
}
fn credentials(headers: &HeaderMap) -> Option<(String, Zeroizing<String>, HeaderValue)> {
+1 -6
View File
@@ -70,8 +70,6 @@ pub struct Config {
pub vhosts: Vec<crate::vhost::Vhost>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub host_policies: Vec<HostPolicy>,
// Preserve old 0.34.13 and earlier config/backup data until compilation migrates it.
// Migration happens in compile_with_active, before validation and persistence.
#[serde(
default,
rename = "user_agent_blocking",
@@ -85,12 +83,9 @@ pub struct Config {
#[serde(default, skip_serializing_if = "HeaderRules::is_empty")]
pub headers: HeaderRules,
}
/// Global HTTP public-listener security. Disabled defaults preserve older configurations.
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct GlobalSecurity {
// None distinguishes omitted legacy input from an explicitly configured empty policy.
// Successful compilation always normalizes this to Some(...).
pub user_agent_blocking: Option<crate::user_agent::Policy>,
pub country_access: crate::rate_limit::CountryAccess,
pub acl: Option<Acl>,
@@ -308,7 +303,7 @@ pub struct CompiledFrontend {
pub metrics: Arc<crate::metrics::VhostMetrics>,
pub server_header: crate::settings::ServerHeader,
pub diagnostic_headers: crate::settings::DiagnosticHeaders,
pub tinyauth: Option<crate::tinyauth::Compiled>,
pub auth_provider: Option<crate::auth_provider::Compiled>,
pub id: String,
pub bindings: Vec<crate::vhost::Listener>,
pub acl_profile_id: Option<String>,
+7 -1
View File
@@ -216,7 +216,13 @@ fn compile_routing(
.diagnostic_headers
.clone()
.unwrap_or_else(|| config.diagnostic_headers.clone()),
tinyauth: host.tinyauth.compile(&desired_policy, &guarded_bindings)?,
auth_provider: crate::auth_provider::Selection {
basic_enabled: host.basic_auth.enabled,
tinyauth: &host.tinyauth,
authelia: &host.authelia,
authentik: &host.authentik,
}
.compile(&desired_policy, &guarded_bindings)?,
id: host.id.clone(),
bindings: host_bindings,
acl_profile_id: host.acl_profile_id.clone(),
+37
View File
@@ -28,6 +28,43 @@ mod tests {
.is_none());
}
#[test]
fn optional_forward_auth_providers_preserve_legacy_vhost_configuration() {
let config = demo_config("127.0.0.1:9000".parse().unwrap());
let encoded = serde_json::to_value(&config.vhosts[0]).unwrap();
let mut legacy = encoded.as_object().unwrap().clone();
legacy.remove("authelia");
legacy.remove("authentik");
let restored: crate::vhost::Vhost = serde_json::from_value(legacy.into()).unwrap();
assert!(!restored.authelia.enabled);
assert!(!restored.authentik.enabled);
}
#[test]
fn forward_auth_selection_must_be_mutually_exclusive() {
for (left, right) in [
("tinyauth", "authelia"),
("authelia", "authentik"),
("authentik", "basic"),
] {
let mut config = demo_config("127.0.0.1:9000".parse().unwrap());
let host = &mut config.vhosts[0];
for enabled in [left, right] {
match enabled {
"tinyauth" => host.tinyauth.enabled = true,
"authelia" => host.authelia.enabled = true,
"authentik" => host.authentik.enabled = true,
"basic" => host.basic_auth.enabled = true,
_ => unreachable!(),
}
}
assert!(
host.validate().is_err(),
"combination {left} and {right} must be refused"
);
}
}
#[test]
fn missing_http2_settings_use_release_defaults() {
let config: Config = serde_json::from_value(serde_json::json!({})).unwrap();
+419
View File
@@ -0,0 +1,419 @@
use crate::{
auth_provider::{self, CookieSink, GatewayFailure, Identity},
config::{Backend, CompiledBackend, UpstreamProtocol, ValidationPolicy},
forwarding,
};
use anyhow::{ensure, Context as _, Result};
use axum::{body::Body, response::Response};
use http::{header, HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, Uri};
use http_body_util::{BodyExt, Limited};
use serde::{Deserialize, Serialize};
use std::{marker::PhantomData, net::SocketAddr, sync::Arc, time::Duration};
use url::Url;
pub trait Adapter: Send + Sync + std::fmt::Debug + 'static {
fn label() -> &'static str;
fn verification_path() -> &'static str;
fn portal(_settings: &Settings) -> Result<Option<Url>> {
Ok(None)
}
fn denied(
status: StatusCode,
response_headers: &HeaderMap,
original_url: &str,
portal: Option<&Url>,
redirect_forbidden: bool,
) -> std::result::Result<Response, Response>;
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Settings {
pub enabled: bool,
pub url: String,
pub login_url: String,
pub allow_private: bool,
pub address_allowlist: Vec<String>,
pub tls_ca_pem: Option<String>,
pub connect_timeout_seconds: u64,
pub timeout_seconds: u64,
pub request_headers: Vec<String>,
pub response_headers: Vec<String>,
pub redirect_forbidden: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
enabled: false,
url: String::new(),
login_url: String::new(),
allow_private: false,
address_allowlist: vec![],
tls_ca_pem: None,
connect_timeout_seconds: 5,
timeout_seconds: 10,
request_headers: vec![
"cookie".into(),
"authorization".into(),
"user-agent".into(),
"accept".into(),
"accept-language".into(),
],
response_headers: vec![
"remote-user".into(),
"remote-groups".into(),
"remote-name".into(),
"remote-email".into(),
"x-authentik-username".into(),
"x-authentik-email".into(),
"x-authentik-name".into(),
"x-authentik-groups".into(),
"x-authentik-uid".into(),
"x-authentik-entitlements".into(),
],
redirect_forbidden: false,
}
}
}
#[derive(Debug)]
pub struct Compiled<A: Adapter> {
pub(crate) backend: Arc<CompiledBackend>,
check_uri: Uri,
portal: Option<Url>,
pub(crate) timeout: Duration,
request_headers: Vec<HeaderName>,
response_headers: Vec<HeaderName>,
_adapter: PhantomData<A>,
redirect_forbidden: bool,
}
pub(crate) fn fail(status: StatusCode) -> Response {
auth_provider::deny(status)
}
pub(crate) fn invalid(provider_name: &'static str, message: &str) -> Response {
tracing::warn!(provider = provider_name, "{message}");
auth_provider::gateway_failure(provider_name, GatewayFailure::Verification)
}
#[derive(Debug)]
enum CheckError {
Connection(anyhow::Error),
InvalidResponse(anyhow::Error),
}
pub(crate) fn http_url(value: &str, label: &str) -> Result<Url> {
ensure!(
(1..=2048).contains(&value.len()),
"{label} URL must be 1..2048 bytes"
);
let url = Url::parse(value)?;
ensure!(
matches!(url.scheme(), "http" | "https") && url.host_str().is_some(),
"{label} URL must use HTTP(S)"
);
ensure!(
url.username().is_empty() && url.password().is_none() && url.fragment().is_none(),
"{label} URL cannot contain credentials or fragments"
);
Ok(url)
}
fn identity_name(name: &HeaderName) -> bool {
matches!(
name.as_str(),
"remote-user"
| "remote-email"
| "remote-name"
| "remote-groups"
| "remote-sub"
| "x-auth-request-user"
| "x-auth-request-email"
| "x-auth-request-groups"
| "x-auth-request-name"
) || name.as_str().starts_with("x-authentik-")
}
pub(crate) fn is_portal_https(url: &Url) -> bool {
url.scheme() == "https"
|| url.host_str().is_some_and(|host| {
host == "localhost"
|| host
.parse::<std::net::IpAddr>()
.is_ok_and(|ip| ip.is_loopback())
})
}
impl Settings {
pub fn validate<A: Adapter>(&self) -> Result<()> {
ensure!(
(1..=30).contains(&self.connect_timeout_seconds)
&& (1..=60).contains(&self.timeout_seconds)
&& self.connect_timeout_seconds <= self.timeout_seconds,
"{} requires connect timeout <= total timeout (1..30s / 1..60s)",
A::label()
);
ensure!(
self.request_headers.len() <= 32 && self.response_headers.len() <= 32,
"{} allows at most 32 headers per direction",
A::label()
);
let mut names = std::collections::HashSet::new();
for raw in &self.request_headers {
ensure!(raw.len() <= 128, "{} request header too long", A::label());
let name: HeaderName = raw.parse()?;
ensure!(
matches!(
name.as_str(),
"cookie"
| "authorization"
| "user-agent"
| "accept"
| "accept-language"
| "x-api-key"
),
"unsafe {} request header: {name}",
A::label()
);
}
for raw in &self.response_headers {
ensure!(raw.len() <= 128, "{} response header too long", A::label());
let name: HeaderName = raw.parse()?;
ensure!(
identity_name(&name) && names.insert(name),
"unsafe or duplicate {} identity header",
A::label()
);
}
if self.enabled {
let url = http_url(&self.url, A::label())?;
ensure!(
url.query().is_none()
&& (url.path() == "/" || url.path() == A::verification_path()),
"{} verifier must be an origin or the documented auth endpoint",
A::label()
);
A::portal(self)?;
}
Ok(())
}
pub fn compile<A: Adapter>(
&self,
policy: &ValidationPolicy,
bindings: &[SocketAddr],
) -> Result<Option<Compiled<A>>> {
self.validate::<A>()?;
if !self.enabled {
return Ok(None);
}
let mut endpoint = http_url(&self.url, A::label())?;
endpoint.set_path(A::verification_path());
let origin = endpoint.origin().ascii_serialization();
let definition = Backend {
id: A::label().to_lowercase(),
url: origin,
allow_private: self.allow_private,
accept_local_listener_risk: false,
preserve_host: false,
protocol: UpstreamProtocol::Http1,
address_allowlist: self.address_allowlist.clone(),
tls_ca_pem: self.tls_ca_pem.clone(),
tls_server_name: None,
tls_verify: true,
};
let keepalive = crate::balance::Keepalive::default();
let mut backend =
crate::config::compile_backend(&definition, policy, bindings, &keepalive)?;
backend.client = crate::upstream::client_with_timeout(
&definition,
backend.address_policy.clone(),
false,
&keepalive,
Duration::from_secs(self.connect_timeout_seconds),
)?;
Ok(Some(Compiled {
backend: Arc::new(backend),
check_uri: endpoint.as_str().parse()?,
portal: A::portal(self)?,
timeout: Duration::from_secs(self.timeout_seconds),
request_headers: self
.request_headers
.iter()
.map(|value| value.parse())
.collect::<std::result::Result<_, _>>()?,
response_headers: self
.response_headers
.iter()
.map(|value| value.parse())
.collect::<std::result::Result<_, _>>()?,
redirect_forbidden: self.redirect_forbidden,
_adapter: PhantomData,
}))
}
}
pub(crate) fn auth_context(
headers: &mut HeaderMap,
method: &Method,
context: &forwarding::Context,
) -> Result<()> {
// Never trust inbound original metadata, including markers from another auth module.
for name in headers
.keys()
.filter(|name| name.as_str().starts_with("x-original-"))
.cloned()
.collect::<Vec<_>>()
{
headers.remove(name);
}
context.apply(headers);
headers.insert("x-original-url", context.original_url.parse()?);
headers.insert("x-original-method", method.as_str().parse()?);
Ok(())
}
pub(crate) fn login_redirect(raw: Option<&HeaderValue>, portal: &Url) -> Option<HeaderValue> {
let raw = raw?.to_str().ok()?;
if raw.len() > 8192 {
return None;
}
let url = if raw.starts_with('/') && !raw.starts_with("//") {
portal.join(raw).ok()?
} else {
Url::parse(raw).ok()?
};
if url.origin() != portal.origin()
|| !url.username().is_empty()
|| url.password().is_some()
|| url.fragment().is_some()
{
return None;
}
url.as_str().parse().ok()
}
impl<A: Adapter> Compiled<A> {
pub fn strip_identity(&self, headers: &mut HeaderMap) {
for name in headers
.keys()
.filter(|name| identity_name(name))
.cloned()
.collect::<Vec<_>>()
{
headers.remove(name);
}
for name in &self.response_headers {
headers.remove(name);
}
}
pub async fn authorize(
&self,
incoming: &HeaderMap,
method: &Method,
context: &forwarding::Context,
shutdown: &tokio_util::sync::CancellationToken,
cookies: &CookieSink,
) -> std::result::Result<Identity, Response> {
let check = tokio::select! {
_ = shutdown.cancelled() => return Err(fail(StatusCode::SERVICE_UNAVAILABLE)),
result = tokio::time::timeout(self.timeout, self.check(incoming, method, context)) => result,
};
let (status, response_headers) = match check {
Ok(Ok(check)) => check,
Ok(Err(CheckError::Connection(error))) => {
tracing::warn!(provider = A::label(), %error, "forward-auth connection failed");
return Err(auth_provider::gateway_failure(
A::label(),
GatewayFailure::Connection,
));
}
Ok(Err(CheckError::InvalidResponse(error))) => {
tracing::warn!(provider = A::label(), %error, "forward-auth response invalid");
return Err(auth_provider::gateway_failure(
A::label(),
GatewayFailure::Verification,
));
}
Err(_) => {
tracing::warn!(provider = A::label(), "forward-auth verifier timed out");
return Err(auth_provider::gateway_failure(
A::label(),
GatewayFailure::Timeout,
));
}
};
if status == StatusCode::OK {
let mut identity = HeaderMap::new();
let mut size = 0usize;
for name in &self.response_headers {
for value in response_headers.get_all(name) {
size += value.as_bytes().len();
if size > 8192 {
return Err(invalid(
A::label(),
"forward-auth identity headers too large",
));
}
identity.append(name.clone(), value.clone());
}
}
if !cookies.collect(&response_headers) {
return Err(invalid(
A::label(),
"forward-auth session headers too large",
));
}
return Ok(Identity { headers: identity });
}
let mut response = A::denied(
status,
&response_headers,
&context.original_url,
self.portal.as_ref(),
self.redirect_forbidden,
)?;
let session = CookieSink::default();
if !session.collect(&response_headers) {
return Err(invalid(
A::label(),
"forward-auth response cookies too large",
));
}
session.apply(&mut response);
Err(response)
}
async fn check(
&self,
incoming: &HeaderMap,
method: &Method,
context: &forwarding::Context,
) -> std::result::Result<(StatusCode, HeaderMap), CheckError> {
let mut request = Request::builder()
.method(Method::GET)
.uri(self.check_uri.clone())
.body(Body::empty())
.map_err(|error| CheckError::InvalidResponse(error.into()))?;
for name in &self.request_headers {
for value in incoming.get_all(name) {
request.headers_mut().append(name.clone(), value.clone());
}
}
crate::proxy::headers::strip_hop_by_hop(request.headers_mut());
crate::proxy::headers::strip_admin_cookie(request.headers_mut());
self.strip_identity(request.headers_mut());
auth_context(request.headers_mut(), method, context)
.map_err(CheckError::InvalidResponse)?;
request
.headers_mut()
.insert(header::HOST, self.backend.host_header.clone());
let response = self
.backend
.client
.request(request)
.await
.context("forward-auth connection")
.map_err(CheckError::Connection)?;
let (parts, body) = response.into_parts();
Limited::new(body, 16 * 1024)
.collect()
.await
.map_err(|error| anyhow::anyhow!("forward-auth verification body: {error}"))
.map_err(CheckError::InvalidResponse)?;
Ok((parts.status, parts.headers))
}
}
#[cfg(test)]
include!("tests.rs");
+251
View File
@@ -0,0 +1,251 @@
mod tests {
use super::*;
use crate::{authelia, authentik};
#[tokio::test]
async fn all_forward_auth_adapters_use_generic_gateway_pages() {
use crate::auth_provider::testing::{context, policy, spawn_mock};
use http_body_util::BodyExt;
let incoming = HeaderMap::new();
for provider in ["Authelia", "authentik"] {
let (url, server) = spawn_mock("503 Service Unavailable", "").await;
let settings = Settings {
enabled: true,
url,
login_url: "https://auth.example.com".into(),
allow_private: true,
..Default::default()
};
let result = if provider == "Authelia" {
settings
.compile::<authelia::Adapter>(&policy(), &[])
.unwrap()
.unwrap()
.authorize(
&incoming,
&Method::GET,
&context(&incoming, "app.example.com"),
&tokio_util::sync::CancellationToken::new(),
&CookieSink::default(),
)
.await
} else {
settings
.compile::<authentik::Adapter>(&policy(), &[])
.unwrap()
.unwrap()
.authorize(
&incoming,
&Method::GET,
&context(&incoming, "app.example.com"),
&tokio_util::sync::CancellationToken::new(),
&CookieSink::default(),
)
.await
};
let response = result.unwrap_err();
assert_eq!(response.status(), StatusCode::BAD_GATEWAY);
let html = String::from_utf8(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(html.contains(&format!("{provider} verification error")));
server.await.unwrap();
}
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let url = format!("http://{}", listener.local_addr().unwrap());
drop(listener);
let settings = Settings {
enabled: true,
url,
login_url: "https://auth.example.com".into(),
allow_private: true,
..Default::default()
};
let context = context(&incoming, "app.example.com");
let shutdown = tokio_util::sync::CancellationToken::new();
let cookies = CookieSink::default();
let result = settings
.compile::<authelia::Adapter>(&policy(), &[])
.unwrap()
.unwrap()
.authorize(&incoming, &Method::GET, &context, &shutdown, &cookies)
.await;
let response = result.unwrap_err();
let html = String::from_utf8(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(html.contains("Authelia connection error"));
let result = settings
.compile::<authentik::Adapter>(&policy(), &[])
.unwrap()
.unwrap()
.authorize(&incoming, &Method::GET, &context, &shutdown, &cookies)
.await;
let response = result.unwrap_err();
let html = String::from_utf8(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(html.contains("authentik connection error"));
}
#[tokio::test]
async fn verifier_deadline_produces_generic_504_page_for_both_adapters() {
use crate::auth_provider::testing::{context, policy};
use http_body_util::BodyExt;
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let settings = Settings {
enabled: true,
url: format!("http://{}", listener.local_addr().unwrap()),
login_url: "https://auth.example.com".into(),
allow_private: true,
connect_timeout_seconds: 1,
timeout_seconds: 1,
..Default::default()
};
let incoming = HeaderMap::new();
let ctx = context(&incoming, "app.example.com");
let shutdown = tokio_util::sync::CancellationToken::new();
let cookies = CookieSink::default();
let response = settings
.compile::<authelia::Adapter>(&policy(), &[])
.unwrap()
.unwrap()
.authorize(&incoming, &Method::GET, &ctx, &shutdown, &cookies)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
let html = String::from_utf8(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(html.contains("Authelia timeout"));
let response = settings
.compile::<authentik::Adapter>(&policy(), &[])
.unwrap()
.unwrap()
.authorize(&incoming, &Method::GET, &ctx, &shutdown, &cookies)
.await
.unwrap_err();
assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT);
let html = String::from_utf8(
response
.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(html.contains("authentik timeout"));
drop(listener);
}
#[test]
fn disabled_forward_auth_configs_are_backward_compatible() {
for empty in ["{}", "{\"enabled\":false}"] {
let settings: Settings = serde_json::from_str(empty).unwrap();
assert!(!settings.enabled);
assert!(settings.validate::<authelia::Adapter>().is_ok());
assert!(settings.validate::<authentik::Adapter>().is_ok());
}
}
#[test]
fn provider_verification_endpoint_and_header_safety_are_checked() {
for provider in ["authelia", "authentik"] {
let mut settings = Settings {
enabled: true,
url: "http://127.0.0.1:9300".into(),
login_url: "https://auth.example.com".into(),
allow_private: true,
..Default::default()
};
let validate = |settings: &Settings| -> Result<()> {
if provider == "authelia" {
settings.validate::<authelia::Adapter>()
} else {
settings.validate::<authentik::Adapter>()
}
};
assert!(validate(&settings).is_ok());
for bad in [
"file:///etc/passwd",
"http://user:password@127.0.0.1:9300",
"http://127.0.0.1:9300/other",
"http://127.0.0.1:9300?q=1",
"https://example/#frag",
] {
settings.url = bad.into();
assert!(validate(&settings).is_err(), "{provider}: {bad}");
}
settings.url = "http://127.0.0.1:9300".into();
settings.request_headers.push("x-forwarded-host".into());
assert!(validate(&settings).is_err());
settings.request_headers.pop();
settings.response_headers.push("authorization".into());
assert!(validate(&settings).is_err());
}
}
#[test]
fn original_request_headers_are_regenerated_and_identity_is_sanitized() {
use crate::auth_provider::testing::{context, policy};
let settings = Settings {
enabled: true,
url: "http://127.0.0.1:9300".into(),
allow_private: true,
..Default::default()
};
let compiled = settings
.compile::<authentik::Adapter>(&policy(), &[])
.unwrap()
.unwrap();
let mut headers = HeaderMap::new();
headers.insert("remote-user", "forged".parse().unwrap());
headers.insert("x-authentik-username", "forged".parse().unwrap());
headers.insert(
"x-original-url",
"https://evil.example/private".parse().unwrap(),
);
headers.insert("x-forwarded-host", "evil.example".parse().unwrap());
let ctx = context(&headers, "app.example.com");
compiled.strip_identity(&mut headers);
auth_context(&mut headers, &Method::POST, &ctx).unwrap();
assert_eq!(
headers["x-original-url"],
"https://app.example.com/private?redirect=%2Fhome"
);
assert_eq!(headers["x-forwarded-host"], "app.example.com");
assert_eq!(headers["x-original-method"], "POST");
assert!(!headers.contains_key("remote-user"));
assert!(!headers.contains_key("x-authentik-username"));
}
}
+4
View File
@@ -31,7 +31,11 @@ pub mod stream;
pub mod system_stats;
pub mod transform;
pub mod auth_provider;
pub mod authelia;
pub mod authentik;
pub mod balance;
pub mod forward_auth;
pub mod forwarding;
pub mod geoip;
pub mod http3;
+1 -2
View File
@@ -267,7 +267,6 @@ fn finish_headers(
request_id: &str,
active: &[crate::vhost::Listener],
) {
// Global security headers form a baseline; explicitly set vhost values take precedence.
snapshot
.global_security
.headers
@@ -283,7 +282,7 @@ fn finish_headers(
.map(|f| &f.diagnostic_headers)
.unwrap_or(&snapshot.envelope.config.diagnostic_headers)
.apply(response.headers_mut(), request_id);
if frontend.is_some_and(|f| f.tinyauth.is_some() || f.basic_auth.is_some()) {
if frontend.is_some_and(|f| f.auth_provider.is_some() || f.basic_auth.is_some()) {
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
+2
View File
@@ -26,6 +26,8 @@ fn disabled_cache_retains_reason_without_matching_force_rules_or_reparsing_heade
"admin_control_plane",
"basic_auth",
"tinyauth",
"authelia",
"authentik",
"sticky_cookie",
"request_upgrade",
] {
+3 -3
View File
@@ -127,8 +127,8 @@ let policy_reason = if admin_control_plane {
Some("admin_control_plane")
} else if frontend.is_some_and(|f| f.basic_auth.is_some()) {
Some("basic_auth")
} else if frontend.is_some_and(|f| f.tinyauth.is_some()) {
Some("tinyauth")
} else if let Some(provider) = frontend.and_then(|f| f.auth_provider.as_ref()) {
Some(provider.id())
} else if route
.pool
.as_ref()
@@ -244,7 +244,7 @@ context.apply_upstream(
.as_ref()
.is_none_or(|frontend| frontend.forward_client_ip),
);
if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
if let Some(auth) = frontend.and_then(|f| f.auth_provider.as_ref()) {
auth.strip_identity(request.headers_mut());
}
if let Some(identity) = identity {
+1 -1
View File
@@ -23,7 +23,7 @@
let transform_settings = route.frontend.as_ref().map(|frontend| &frontend.transform);
let scope = route.frontend.as_ref().map(|frontend| frontend.id.as_str()).unwrap_or("");
let enabled = cache_settings.active_for(route.cache)
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.tinyauth.is_none());
&& frontend.is_none_or(|frontend| frontend.basic_auth.is_none() && frontend.auth_provider.is_none());
if let Some(exchange) = request.extensions().get::<Arc<Exchange>>() {
exchange.set_cache_enabled(enabled);
exchange.set_transform_enabled(transform_settings.is_some_and(|settings| settings.enabled));
+26 -4
View File
@@ -2,9 +2,13 @@ async fn forward(
State(state): State<Arc<App>>,
Extension(peer): Extension<PeerAddr>,
Extension(local): Extension<LocalAddr>,
request: Request,
mut request: Request,
) -> Response {
forward_request(state, peer, local, request).await
let cookies = crate::auth_provider::CookieSink::default();
request.extensions_mut().insert(cookies.clone());
let mut response = forward_request(state, peer, local, request).await;
cookies.apply(&mut response);
response
}
async fn forward_request(
@@ -197,7 +201,7 @@ async fn forward_request(
tls,
&original_host,
request.uri(),
frontend.is_some_and(|frontend| frontend.tinyauth.is_some()),
frontend.is_some_and(|frontend| frontend.auth_provider.is_some()),
request
.extensions()
.get::<Arc<crate::forwarding::PeerFormatting>>()
@@ -464,6 +468,18 @@ async fn forward_request(
.filter(|route| route.error_fallback.is_some())
.map(|_| ErrorFallbackRequest::capture(&request, &snapshot));
let admin_cookie = headers::strip_admin_cookie(request.headers_mut());
if let Some(auth) = frontend.and_then(|frontend| frontend.auth_provider.as_ref()) {
if auth.reserved_path(&path) {
return match auth.serve_reserved(request, &context, &state.shutdown) {
Some(response) => response.await,
None => error(
StatusCode::BAD_GATEWAY,
"RustProxy: authentication route unavailable\n",
),
};
}
}
let basic_identity = if let Some(auth) = frontend.and_then(|f| f.basic_auth.as_ref()) {
match auth
.authorize(
@@ -487,14 +503,20 @@ async fn forward_request(
"RustProxy: WebSocket access is disabled for this host\n",
);
}
let identity = if let Some(auth) = frontend.and_then(|f| f.tinyauth.as_ref()) {
let identity = if let Some(auth) = frontend.and_then(|f| f.auth_provider.as_ref()) {
auth.strip_identity(request.headers_mut());
let cookies = request
.extensions()
.get::<crate::auth_provider::CookieSink>()
.cloned()
.unwrap_or_default();
match auth
.authorize(
request.headers(),
request.method(),
&context,
&state.shutdown,
&cookies,
)
.await
{
+21 -3
View File
@@ -37,7 +37,7 @@ fn should_replace_error_page(response: &Response, minimal_welcome: bool) -> bool
.is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
.get::<crate::auth_provider::AuthenticationResponse>()
.is_none()
}
@@ -285,7 +285,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
.get::<crate::auth_provider::AuthenticationResponse>()
.is_none()
{
apply_response_rules(
@@ -385,7 +385,7 @@ async fn observe(State(state): State<Arc<App>>, mut request: Request, next: Next
.is_none()
&& response
.extensions()
.get::<crate::tinyauth::AuthenticationFailure>()
.get::<crate::auth_provider::AuthenticationResponse>()
.is_none()
{
let outcome = match exchange.cache_status.load(Ordering::Relaxed) {
@@ -513,3 +513,21 @@ fn effective_host(request: &Request, tls: bool) -> Result<HeaderValue, ()> {
request.headers().get(HOST).cloned().ok_or(())
}
}
#[cfg(test)]
mod auth_provider_page_interception_tests {
use super::*;
#[test]
fn provider_failures_and_redirects_bypass_custom_http_error_pages() {
for kind in [
crate::auth_provider::GatewayFailure::Connection,
crate::auth_provider::GatewayFailure::Timeout,
crate::auth_provider::GatewayFailure::Verification,
] {
let response = crate::auth_provider::gateway_failure("Future", kind);
assert!(!should_replace_error_page(&response, false));
}
let response = crate::auth_provider::deny(StatusCode::UNAUTHORIZED);
assert!(!should_replace_error_page(&response, false));
}
}
+2
View File
@@ -144,6 +144,8 @@ fn vhost_cache_revision(vhost: &crate::vhost::Vhost) -> u64 {
view.content_type_policy = Default::default();
view.basic_auth = Default::default();
view.tinyauth = Default::default();
view.authelia = Default::default();
view.authentik = Default::default();
view.acl = None;
view.acl_profile_id = None;
view.user_agent_blocking = Default::default();
+44 -76
View File
@@ -1,12 +1,10 @@
use crate::{
auth_provider::{self, GatewayFailure, Identity},
config::{Backend, CompiledBackend, UpstreamProtocol, ValidationPolicy},
forwarding,
};
use anyhow::{ensure, Context as _, Result};
use axum::{
body::Body,
response::{IntoResponse, Response},
};
use axum::{body::Body, response::Response};
use http::{HeaderMap, HeaderName, HeaderValue, Method, Request, StatusCode, Uri};
use http_body_util::{BodyExt, Limited};
use serde::{Deserialize, Serialize};
@@ -61,57 +59,12 @@ pub struct Compiled {
response_headers: Vec<HeaderName>,
redirect_forbidden: bool,
}
#[derive(Clone)]
pub struct AuthenticationFailure;
#[derive(Debug)]
enum CheckError {
Connection(anyhow::Error),
InvalidResponse(anyhow::Error),
}
#[derive(Debug, Clone, Copy)]
enum GatewayFailure {
Connection,
Timeout,
Verification,
}
fn gateway_failure(kind: GatewayFailure) -> Response {
let (status, html) = match kind {
GatewayFailure::Connection => (
StatusCode::BAD_GATEWAY,
include_str!("../admin/web/error_tinyauth_connection.html"),
),
GatewayFailure::Timeout => (
StatusCode::GATEWAY_TIMEOUT,
include_str!("../admin/web/error_tinyauth_timeout.html"),
),
GatewayFailure::Verification => (
StatusCode::BAD_GATEWAY,
include_str!("../admin/web/error_tinyauth_verification.html"),
),
};
let mut response = (status, html).into_response();
response.headers_mut().insert(
"content-type",
HeaderValue::from_static("text/html; charset=utf-8"),
);
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
);
response.headers_mut().insert(
"x-content-type-options",
HeaderValue::from_static("nosniff"),
);
response.extensions_mut().insert(AuthenticationFailure);
response
}
#[derive(Debug, Default)]
pub struct Identity {
headers: HeaderMap,
}
const STANDARD_IDENTITY: [&str; 6] = [
"remote-user",
"remote-email",
@@ -252,18 +205,7 @@ impl Settings {
}
}
fn failure(status: StatusCode) -> Response {
let body = format!(
"{} {}\n",
status.as_u16(),
status.canonical_reason().unwrap_or("Error")
);
let mut response = (status, body).into_response();
response.headers_mut().insert(
"cache-control",
HeaderValue::from_static("private, no-store"),
);
response.extensions_mut().insert(AuthenticationFailure);
response
auth_provider::deny(status)
}
fn apply_nginx_auth_context(
@@ -362,15 +304,24 @@ impl Compiled {
Ok(Ok(value)) => value,
Ok(Err(CheckError::Connection(error))) => {
tracing::warn!(%error, "Tinyauth backend connection failed");
return Err(gateway_failure(GatewayFailure::Connection));
return Err(auth_provider::gateway_failure(
"Tinyauth",
GatewayFailure::Connection,
));
}
Ok(Err(CheckError::InvalidResponse(error))) => {
tracing::warn!(%error, "Tinyauth returned an invalid verification response");
return Err(gateway_failure(GatewayFailure::Verification));
return Err(auth_provider::gateway_failure(
"Tinyauth",
GatewayFailure::Verification,
));
}
Err(_) => {
tracing::warn!("Tinyauth verification timed out");
return Err(gateway_failure(GatewayFailure::Timeout));
return Err(auth_provider::gateway_failure(
"Tinyauth",
GatewayFailure::Timeout,
));
}
};
if status.is_success() {
@@ -381,7 +332,10 @@ impl Compiled {
size += value.as_bytes().len();
if size > 8192 {
tracing::warn!("Tinyauth identity headers exceed the configured limit");
return Err(gateway_failure(GatewayFailure::Verification));
return Err(auth_provider::gateway_failure(
"Tinyauth",
GatewayFailure::Verification,
));
}
identity.headers.append(name.clone(), value.clone());
}
@@ -408,7 +362,10 @@ impl Compiled {
status = status.as_u16(),
"Tinyauth returned an unexpected HTTP status"
);
Err(gateway_failure(GatewayFailure::Verification))
Err(auth_provider::gateway_failure(
"Tinyauth",
GatewayFailure::Verification,
))
}
async fn check(
&self,
@@ -451,15 +408,26 @@ impl Compiled {
Ok((parts.status, parts.headers))
}
}
impl Identity {
pub fn apply(&self, headers: &mut HeaderMap) {
for name in self.headers.keys() {
headers.remove(name);
}
for (name, value) in &self.headers {
headers.append(name.clone(), value.clone());
}
}
}
#[cfg(test)]
include!("tests.rs");
impl crate::auth_provider::Provider for Compiled {
fn id(&self) -> &'static str {
"tinyauth"
}
fn strip_identity(&self, headers: &mut HeaderMap) {
Compiled::strip_identity(self, headers);
}
fn authorize<'a>(
&'a self,
incoming: &'a HeaderMap,
method: &'a Method,
context: &'a forwarding::Context,
shutdown: &'a tokio_util::sync::CancellationToken,
_cookies: &'a crate::auth_provider::CookieSink,
) -> crate::auth_provider::Authorization<'a> {
Box::pin(Compiled::authorize(
self, incoming, method, context, shutdown,
))
}
}
+2 -2
View File
@@ -208,7 +208,7 @@ mod tests {
"Tinyauth timeout",
),
] {
let response = gateway_failure(kind);
let response = auth_provider::gateway_failure("Tinyauth", kind);
assert_eq!(response.status(), status);
assert_eq!(
response.headers()["content-type"],
@@ -218,7 +218,7 @@ mod tests {
assert_eq!(response.headers()["x-content-type-options"], "nosniff");
assert!(response
.extensions()
.get::<AuthenticationFailure>()
.get::<auth_provider::AuthenticationResponse>()
.is_some());
let body = response.into_body().collect().await.unwrap().to_bytes();
let html = std::str::from_utf8(&body).unwrap();
+15 -6
View File
@@ -54,7 +54,10 @@ pub struct MethodPolicy {
}
impl MethodPolicy {
pub fn validate(&mut self) -> Result<()> {
ensure!(self.methods.len() <= 32, "method policy allows at most 32 methods");
ensure!(
self.methods.len() <= 32,
"method policy allows at most 32 methods"
);
let mut seen = std::collections::HashSet::new();
for method in &mut self.methods {
*method = method.trim().to_ascii_uppercase();
@@ -62,7 +65,10 @@ impl MethodPolicy {
!method.is_empty() && method.len() <= 32 && method.parse::<http::Method>().is_ok(),
"invalid HTTP method in method policy"
);
ensure!(seen.insert(method.clone()), "duplicate HTTP method in method policy");
ensure!(
seen.insert(method.clone()),
"duplicate HTTP method in method policy"
);
}
ensure!(
self.mode == MethodPolicyMode::Off || !self.methods.is_empty(),
@@ -115,9 +121,7 @@ impl ContentTypePolicy {
anyhow::bail!("invalid blocked Content-Type: expected type/subtype");
};
ensure!(
mime_token(kind)
&& (subtype == "*" || mime_token(subtype))
&& value.len() <= 255,
mime_token(kind) && (subtype == "*" || mime_token(subtype)) && value.len() <= 255,
"invalid blocked Content-Type: use type/subtype or type/* without parameters"
);
ensure!(
@@ -346,6 +350,10 @@ pub struct Vhost {
#[serde(default)]
pub tinyauth: crate::tinyauth::Settings,
#[serde(default)]
pub authelia: crate::authelia::Settings,
#[serde(default)]
pub authentik: crate::authentik::Settings,
#[serde(default)]
pub certificate_id: Option<String>,
#[serde(default)]
pub allow_certificate_mismatch: bool,
@@ -379,7 +387,8 @@ pub struct Vhost {
pub headers: HeaderRules,
#[serde(default)]
pub security_headers: SecurityHeaders,
#[serde(default)] pub user_agent_blocking: crate::user_agent::Policy,
#[serde(default)]
pub user_agent_blocking: crate::user_agent::Policy,
#[serde(default)]
pub proxy_timeouts: ProxyTimeouts,
#[serde(skip)]
+7 -5
View File
@@ -242,12 +242,14 @@ impl Vhost {
if let Some(server) = &self.server_header {
server.validate()?;
}
self.tinyauth.validate()?;
self.basic_auth.compile()?;
ensure!(
!(self.tinyauth.enabled && self.basic_auth.enabled),
"choose either Tinyauth or Basic authentication"
);
crate::auth_provider::Selection {
basic_enabled: self.basic_auth.enabled,
tinyauth: &self.tinyauth,
authelia: &self.authelia,
authentik: &self.authentik,
}
.validate()?;
self.pages.validate()?;
self.transform
.resolve(&crate::transform::Settings::default())?;