Files
sqlpage--sqlpage/tests/errors/mod.rs
Ophir LOJKINE d92a7399e2 fix: enforce unprivileged path guard on fresh cache hits (#1307)
Reserved/private SQL files (sqlpage/ prefix, dotfiles, .. traversal,
absolute paths) became directly routable over HTTP while their parsed
form was fresh in sql_file_cache. A trusted page loading such a file via
sqlpage.run_sql(...) loads it with privilege and caches it; a later
direct unprivileged request hit the fresh cache entry before the path
guard ran, returning 200 and executing the private SQL instead of 403.

The unprivileged path validation is extracted into
filesystem::validate_unprivileged_path and now enforced before
consulting the cache in both HTTP routing (AppFileStore::contains) and
the unprivileged FileCache::get_with_privilege path.
2026-06-10 16:37:20 +02:00

176 lines
6.3 KiB
Rust

use actix_web::{
http::{self, StatusCode},
test,
};
use sqlpage::{AppState, webserver::http::main_handler};
use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config};
mod basic_auth;
mod invalid_header;
/// Sends a direct unprivileged GET request through the main handler and returns the
/// resulting HTTP status, whether the handler returned an `Ok` response or an `Err`.
async fn direct_request_status(path: &str, app_data: actix_web::web::Data<AppState>) -> StatusCode {
let req = test::TestRequest::get()
.uri(path)
.app_data(app_data)
.insert_header(actix_web::http::header::Accept::html())
.to_srv_request();
match main_handler(req).await {
Ok(resp) => resp.status(),
Err(e) => e.error_response().status(),
}
}
/// Regression test for a cache privilege-escalation bug.
///
/// `sqlpage.run_sql(...)` loads include files with privilege, so it is allowed to
/// load reserved files under the `sqlpage/` prefix and stores their parsed form in
/// the shared `sql_file_cache`. A subsequent *direct* unprivileged HTTP request for
/// that same reserved path must still be rejected with 403, even while the cache
/// entry is fresh. Before the fix, the fresh cache hit short-circuited the
/// unprivileged path guard and the private SQL was executed and served.
#[actix_web::test]
async fn test_private_path_not_accessible_after_privileged_cache_priming() {
// Keep cache entries "fresh" so the bug (skipping the path guard on fresh hits) is exercised.
let mut config = test_config();
config.cache_stale_duration_ms = Some(60_000);
let app_data = make_app_data_from_config(config).await;
// 1. A trusted page primes the cache by loading the reserved file with privilege.
let prime = req_path_with_app_data("/tests/errors/prime_private_cache.sql", app_data.clone())
.await
.expect("priming page should render");
assert_eq!(prime.status(), StatusCode::OK);
let prime_body = String::from_utf8(test::read_body(prime).await.to_vec()).unwrap();
assert!(
prime_body.contains("private cache bypass secret"),
"priming page should have executed the private file via run_sql, got: {prime_body}"
);
// 2. A direct unprivileged HTTP request for the reserved path must stay forbidden.
for path in [
"/sqlpage/private_cache_bypass_test.sql",
// Extensionless alias: routing appends .sql and finds the fresh cache entry.
"/sqlpage/private_cache_bypass_test",
] {
let status = direct_request_status(path, app_data.clone()).await;
assert_eq!(
status,
StatusCode::FORBIDDEN,
"{path} must be forbidden even after privileged cache priming, got {status}"
);
}
}
#[actix_web::test]
async fn test_privileged_paths_are_not_accessible() {
let resp_result = req_path("/sqlpage/migrations/0001_init.sql").await;
assert!(
resp_result.is_err(),
"Accessing a migration file should be forbidden, but received success: {resp_result:?}"
);
let resp = resp_result.unwrap_err().error_response();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
let srv_resp = actix_web::test::TestRequest::default().to_srv_response(resp);
let body = test::read_body(srv_resp).await;
assert!(
String::from_utf8_lossy(&body)
.to_lowercase()
.contains("forbidden"),
);
}
#[actix_web::test]
async fn test_404_fallback() {
for f in [
"/tests/errors/does_not_exist.sql",
"/tests/errors/does_not_exist.html",
"/tests/errors/does_not_exist/",
] {
let resp_result = req_path(f).await;
let resp = resp_result.unwrap();
assert_eq!(resp.status(), http::StatusCode::OK, "{f} isnt 200");
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("But the "));
assert!(body.contains("404.sql"));
assert!(body.contains("file saved the day!"));
assert!(!body.contains("error"));
}
}
#[actix_web::test]
async fn test_default_404() {
for f in [
"/i-do-not-exist.html",
"/i-do-not-exist.sql",
"/i-do-not-exist/",
] {
let resp_result = req_path(f).await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"{f} should return 404"
);
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
let msg = "The page you were looking for does not exist";
assert!(
body.contains(msg),
"{f} should contain '{msg}' but got:\n{body}"
);
assert!(!body.contains("error"));
}
}
#[actix_web::test]
async fn test_default_404_with_redirect() {
let resp_result = req_path("/i-do-not-exist").await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"/i-do-not-exist should return 404"
);
let resp_result = req_path("/i-do-not-exist/").await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"/i-do-not-exist/ should return 404"
);
let body = test::read_body(resp).await;
assert!(body.starts_with(b"<!DOCTYPE html>"));
let body = String::from_utf8(body.to_vec()).unwrap();
let msg = "The page you were looking for does not exist";
assert!(
body.contains(msg),
"/i-do-not-exist/ should contain '{msg}' but got:\n{body}"
);
assert!(!body.contains("error"));
}
#[actix_web::test]
async fn test_default_404_when_request_path_descends_into_file() {
let resp_result = req_path("/tests/it_works.txt/site/wp-includes/wlwmanifest.xml").await;
let resp = resp_result.unwrap();
assert_eq!(
resp.status(),
http::StatusCode::NOT_FOUND,
"descending into a file path should behave like a missing resource"
);
let body = test::read_body(resp).await;
let body = String::from_utf8(body.to_vec()).unwrap();
assert!(body.contains("The page you were looking for does not exist"));
assert!(!body.contains("error"));
}