d718c5a372
CI / compile_and_lint (push) Failing after 0s
CI / docker_build (linux/amd64, -linux-amd64-duckdb, duckdb) (push) Failing after 0s
CI / docker_build (linux/arm64, -linux-arm64, minimal) (push) Failing after 2s
CI / docker_build (linux/arm64, -linux-arm64-duckdb, duckdb) (push) Failing after 1s
CI / docker_build (linux/amd64, minimal) (push) Failing after 1s
CI / test (, sqlite, sqlite::memory:) (push) Has been skipped
CI / test (mssql, mssql, mssql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (mysql, mysql, mysql://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / test (oracle, oracle, Driver=Oracle 21 ODBC driver;Dbq=//127.0.0.1:1521/FREEPDB1;Uid=root;Pwd=Password123!) (push) Has been skipped
CI / test (postgres, odbc, Driver=PostgreSQL Unicode;Server=127.0.0.1;Port=5432;Database=sqlpage;UID=root;PWD=Password123!, true) (push) Has been skipped
CI / test (postgres, postgres, postgres://root:Password123!@127.0.0.1/sqlpage) (push) Has been skipped
CI / playwright (push) Has been skipped
CI / docker_build (linux/arm/v7, -linux-arm-v7, minimal) (push) Failing after 0s
CI / hurl_examples (push) Failing after 8s
deploy website / deploy_official_site (push) Failing after 1s
CI / hurl (${{ matrix.example }}) (push) Has been skipped
CI / docker_push (duckdb) (push) Has been cancelled
CI / docker_push (minimal) (push) Has been cancelled
CI / windows_test (push) Has been cancelled
176 lines
6.3 KiB
Rust
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"));
|
|
}
|