Files
RustProxy/src/admin/session.rs
T

540 lines
19 KiB
Rust

fn diagnostic_admin_authority(value: &str) -> &str {
if value.len() <= 255 && !value.contains('@') && value.parse::<http::uri::Authority>().is_ok() {
value
} else {
"(invalid)"
}
}
fn diagnostic_admin_origin(headers: &HeaderMap) -> &str {
if !headers.contains_key(ORIGIN) {
return "(absent)";
}
let Some(value) = single_header(headers, ORIGIN.as_str()) else {
return "(invalid)";
};
if value == "null" {
return "null";
}
if value.len() > 255 || value.contains('@') || value.contains('#') {
return "(invalid)";
}
let Ok(uri) = value.parse::<http::Uri>() else {
return "(invalid)";
};
if !matches!(uri.scheme_str(), Some("http" | "https"))
|| uri.query().is_some()
|| !matches!(uri.path(), "" | "/")
|| uri.authority().is_none()
{
return "(invalid)";
}
value
}
fn diagnostic_forwarded_header<'a>(headers: &'a HeaderMap, name: &str, trusted: bool) -> &'a str {
if !headers.contains_key(name) {
return "(absent)";
}
if !trusted {
return "(untrusted)";
}
let Some(value) = single_header(headers, name) else {
return "(invalid)";
};
if name == "x-forwarded-proto" {
if matches!(value, "http" | "https") {
value
} else {
"(invalid)"
}
} else {
diagnostic_admin_authority(value)
}
}
fn invalid_admin_origin(
request: &Request,
reason: &'static str,
scheme: Option<&str>,
authority: Option<&str>,
trusted_host: bool,
trusted_proto: bool,
) -> Response {
let headers = request.headers();
let path = request.uri().path();
let path = if path.len() <= 160 {
path
} else {
"(long_path)"
};
let browser_host = single_header(headers, HOST.as_str())
.map(diagnostic_admin_authority)
.unwrap_or("(absent_or_duplicate)");
let browser_fetch_site = match single_header(headers, "sec-fetch-site") {
None => "(absent)",
Some("same-origin" | "same-site" | "cross-site" | "none") => {
single_header(headers, "sec-fetch-site").unwrap_or("(invalid)")
}
_ => "(invalid)",
};
tracing::warn!(
reason,
method = %request.method(),
path,
browser_origin = diagnostic_admin_origin(headers),
browser_host,
expected_scheme = scheme.unwrap_or("(unknown)"),
expected_authority = authority.map(diagnostic_admin_authority).unwrap_or("(unknown)"),
browser_fetch_site,
trusted_forwarded_host = trusted_host,
trusted_forwarded_proto = trusted_proto,
forwarded_host = diagnostic_forwarded_header(headers, "x-forwarded-host", trusted_host),
forwarded_proto = diagnostic_forwarded_header(headers, "x-forwarded-proto", trusted_proto),
"administration origin rejected"
);
let wants_html = request
.headers()
.get(http::header::ACCEPT)
.and_then(|header| header.to_str().ok())
.is_some_and(|value| {
value
.split(',')
.any(|entry| entry.trim_start().starts_with("text/html"))
});
if request.method() == Method::GET && panel_document_path(request.uri().path()) && wants_html {
return secure((
StatusCode::FORBIDDEN,
axum::response::Html("<!doctype html><html lang=\"en\"><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>RustProxy - Access blocked</title><h1>RustProxy - Could not open the panel</h1><p>The request origin was rejected. Check the proxy/URL and try again.</p><p><a href=\"\">Reload panel</a></p></html>"),
).into_response());
}
secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"))
}
async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
let transport = request
.extensions()
.get::<Transport>()
.copied()
.unwrap_or_default();
let peer = request.extensions().get::<PeerAddr>().copied();
let raw_host = match admin_request_authority(&request, transport.tls) {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
let headers = request.headers();
let trusted_host = trusted_admin_proxy(&state, peer);
let trusted_proto = trusted_admin_forwarded_proto(&state, peer);
let automation_credentials =
headers.contains_key(CERTIFICATE_API_KEY_HEADER) || headers.contains_key(AUTHORIZATION);
let pinned_authority_result = if automation_credentials {
Ok(None)
} else {
configured_admin_public_authority(&state, headers, peer, &raw_host, transport)
};
let pinned_authority = match pinned_authority_result {
Ok(value) => value,
Err(()) => {
return invalid_admin_origin(
&request,
"pinned_origin_mismatch",
Some("https"),
state
.active_administration
.public_origin
.as_deref()
.and_then(|value| value.strip_prefix("https://")),
trusted_host,
trusted_proto,
)
}
};
let (scheme, external_authority) = if let Some(authority) = pinned_authority {
("https", authority)
} else {
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
Ok(value) => value,
Err(()) => {
return invalid_admin_origin(
&request,
"external_scheme",
None,
None,
trusted_host,
trusted_proto,
);
}
};
let authority =
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
Ok(value) => value,
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
};
(scheme, authority)
};
if headers.get_all(ORIGIN).iter().count() > 1
|| headers.get(ORIGIN).is_some_and(|h| {
h.to_str()
.ok()
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
})
{
return invalid_admin_origin(
&request,
"origin_mismatch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
}
if headers
.get("sec-fetch-site")
.is_some_and(|h| h == "cross-site")
{
return invalid_admin_origin(
&request,
"cross_site_fetch",
Some(scheme),
Some(&external_authority),
trusted_host,
trusted_proto,
);
}
let path = request.uri().path().to_owned();
let public = anonymous_allowed(request.method(), &path);
let unsafe_method = request.method() != Method::GET
&& request.method() != Method::HEAD
&& request.method() != Method::OPTIONS;
let secret = auth::cookie_secret(headers);
let certificate_api_operation = certificate_api_operation(request.method(), &path);
let mut certificate_api_access = None;
let certificate_api_session = if headers.contains_key(CERTIFICATE_API_KEY_HEADER) {
let Some(operation) = certificate_api_operation.as_ref() else {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.invalid_route",
));
};
match certificate_api_credentials(headers) {
Ok(None) => None,
Ok(Some((key_id, token))) => {
if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.https_required",
));
}
let token_hash = auth::secret_hash(&token);
let grant = match state
.store
.verify_certificate_api_credential(&key_id, &token_hash)
.await
{
Ok(Some(grant)) => grant,
Ok(None) => {
return secure(reject(
StatusCode::UNAUTHORIZED,
"certificate_api.invalid_token",
))
}
Err(error) => return secure(auth::internal(error).into_response()),
};
match operation {
CertificateApiOperation::ReloadAll => {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.target_required",
));
}
CertificateApiOperation::ReloadOne(certificate_id) => {
if !grant.allows_certificate(certificate_id) {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.certificate_forbidden",
));
}
}
CertificateApiOperation::Deploy => {
if !grant.allow_deploy {
return secure(reject(
StatusCode::FORBIDDEN,
"certificate_api.deploy_forbidden",
));
}
}
}
certificate_api_access = Some(CertificateApiAccess { grant });
Some(Session {
token_hash,
csrf: String::new(),
username: format!("certificate-api:{key_id}"),
expires_at: auth::now().saturating_add(60),
generation: 0,
})
}
Err(()) => {
return secure(reject(
StatusCode::UNAUTHORIZED,
"certificate_api.invalid_token",
))
}
}
} else {
None
};
let general_api_session =
if certificate_api_session.is_none() && headers.contains_key(AUTHORIZATION) {
let bearer = single_header(headers, AUTHORIZATION.as_str())
.and_then(|header| header.strip_prefix("Bearer "));
let Some(token) = bearer.filter(|value| valid_certificate_api_token(value)) else {
return secure(reject(StatusCode::UNAUTHORIZED, "api_token.invalid_token"));
};
let Some(peer) = peer else {
return secure(reject(StatusCode::FORBIDDEN, "api_token.peer_required"));
};
if !peer.0.ip().is_loopback() && scheme != "https" {
return secure(reject(StatusCode::FORBIDDEN, "api_token.https_required"));
}
let hash = auth::secret_hash(token);
let peer_ip = crate::vhost::canonical_ip(peer.0.ip());
let grant = match state.store.verify_api_token(&hash, peer_ip).await {
Ok(Some(grant)) => grant,
Ok(None) => {
return secure(reject(
StatusCode::UNAUTHORIZED,
"api_token.invalid_token_or_ip",
))
}
Err(error) => return secure(auth::internal(error).into_response()),
};
if !general_api_token_allowed(request.method(), &path, &grant.permission) {
return secure(reject(
StatusCode::FORBIDDEN,
"api_token.insufficient_permission",
));
}
Some(Session {
token_hash: hash,
csrf: String::new(),
username: format!("api-token:{}", grant.id),
expires_at: auth::now().saturating_add(60),
generation: 0,
})
} else {
None
};
let external_credential = certificate_api_session.is_some() || general_api_session.is_some();
let session_seconds = state
.config
.load()
.envelope
.config
.administration
.session_seconds();
let session = if certificate_api_session.is_some() {
certificate_api_session
} else if general_api_session.is_some() {
general_api_session
} else if !public || panel_document_path(&path) {
match secret.as_deref() {
Some(secret) => match state
.store
.session_at_with_browser_lifetime(secret, auth::now(), session_seconds)
.await
{
Ok(value) => value,
Err(error) => return secure(auth::internal(error).into_response()),
},
None => None,
}
} else {
None
};
if !public && session.is_none() {
return secure(unauthorized(&request));
}
if unsafe_method && !external_credential {
if single_header(headers, "origin")
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|| single_header(headers, "x-rustproxy-request") != Some("1")
{
return secure(reject(StatusCode::FORBIDDEN, "auth.csrf"));
}
if !public
&& !session.as_ref().is_some_and(|session| {
single_header(headers, "x-csrf-token")
.is_some_and(|provided| auth::equal_secret(provided, &session.csrf))
})
{
return secure(reject(StatusCode::FORBIDDEN, "auth.csrf"));
}
}
if let Some(session) = session.clone() {
request.extensions_mut().insert(session);
}
if let Some(access) = certificate_api_access {
request.extensions_mut().insert(access);
}
let admin_origin = AdminOrigin {
secure: scheme == "https",
};
request.extensions_mut().insert(admin_origin);
request.extensions_mut().insert(transport);
let mut response = secure(next.run(request).await);
let refresh_session_cookie = !matches!(
path.as_str(),
"/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password"
);
if refresh_session_cookie && !external_credential {
if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) {
let remaining = session.expires_at.saturating_sub(auth::now());
if remaining > 0 {
if let Ok(cookie) = HeaderValue::from_str(&auth::session_cookie(
secret,
admin_origin.secure,
remaining,
)) {
response.headers_mut().append(SET_COOKIE, cookie);
}
}
}
}
if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() {
response.headers_mut().insert("content-security-policy", HeaderValue::from_static(
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
}
response
}
async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
Ok(Json(
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
))
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Setup {
token: String,
username: String,
password: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Login {
username: String,
password: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct PasswordChange {
current_password: String,
new_password: String,
}
async fn setup(
State(state): State<Arc<App>>,
input: Result<Json<Setup>, axum::extract::rejection::JsonRejection>,
) -> Result<Json<Value>, ApiError> {
let Json(input) = input.map_err(public::invalid_json)?;
state
.auth
.setup(&input.token, &input.username, input.password)
.await?;
Ok(Json(json!({"initialized":true})))
}
fn session_payload(session: &Session, session_seconds: i64) -> Value {
json!({"username":session.username, "csrf_token":session.csrf, "expires_at":session.expires_at,
"idle_timeout_seconds":session_seconds, "session_lifetime_seconds":session_seconds})
}
async fn login(
State(state): State<Arc<App>>,
Extension(origin): Extension<AdminOrigin>,
headers: HeaderMap,
input: Result<Json<Login>, axum::extract::rejection::JsonRejection>,
) -> Result<Response, ApiError> {
let Json(input) = input.map_err(public::invalid_json)?;
let previous = auth::cookie_secret(&headers);
let session_seconds = state
.config
.load()
.envelope
.config
.administration
.session_seconds();
let issued = state
.auth
.login(
&input.username,
input.password,
previous.as_deref(),
session_seconds,
)
.await?;
let mut response = Json(session_payload(&issued.session, session_seconds)).into_response();
response.headers_mut().insert(
SET_COOKIE,
auth::session_cookie(&issued.secret, origin.secure, session_seconds)
.parse()
.map_err(auth::internal)?,
);
Ok(response)
}
async fn session(
State(state): State<Arc<App>>,
Extension(session): Extension<Session>,
) -> Json<Value> {
let session_seconds = state
.config
.load()
.envelope
.config
.administration
.session_seconds();
Json(session_payload(&session, session_seconds))
}
fn logged_out(secure_cookie: bool) -> Response {
let mut response = Json(json!({"logged_out":true})).into_response();
response.headers_mut().insert(
SET_COOKIE,
HeaderValue::from_str(&auth::expired_cookie(secure_cookie))
.expect("static cookie attributes"),
);
response
}
async fn logout(
State(state): State<Arc<App>>,
Extension(origin): Extension<AdminOrigin>,
Extension(session): Extension<Session>,
) -> Result<Response, ApiError> {
state
.store
.end_session(&session)
.await
.map_err(auth::internal)?;
Ok(logged_out(origin.secure))
}
async fn logout_all(
State(state): State<Arc<App>>,
Extension(origin): Extension<AdminOrigin>,
Extension(session): Extension<Session>,
) -> Result<Response, ApiError> {
state
.store
.revoke_all_sessions(&session.username)
.await
.map_err(auth::internal)?;
Ok(logged_out(origin.secure))
}
async fn password(
State(state): State<Arc<App>>,
Extension(origin): Extension<AdminOrigin>,
Extension(session): Extension<Session>,
Json(input): Json<PasswordChange>,
) -> Result<Response, ApiError> {
state
.auth
.change_password(&session, input.current_password, input.new_password)
.await?;
Ok(logged_out(origin.secure))
}