540 lines
19 KiB
Rust
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))
|
|
}
|