chore: import upstream snapshot with attribution
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
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
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
SELECT 'alert' AS component,
|
||||
'We almost got an oopsie' AS title,
|
||||
'But the `404.sql` file saved the day!' AS description_md;
|
||||
@@ -0,0 +1,43 @@
|
||||
use crate::common::{get_request_to, req_path};
|
||||
use actix_web::{http::StatusCode, test};
|
||||
use sqlpage::webserver::http::main_handler;
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_basic_auth_not_provided() {
|
||||
let resp_result = req_path("/tests/errors/basic_auth.sql").await;
|
||||
let resp = resp_result.unwrap();
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
|
||||
assert_eq!(
|
||||
resp.headers().get("www-authenticate").unwrap(),
|
||||
"Basic realm=\"Authentication required\", charset=\"UTF-8\""
|
||||
);
|
||||
let body = test::read_body(resp).await;
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(
|
||||
body_str.contains("Unauthorized"),
|
||||
"{body_str}\nexpected to contain Unauthorized"
|
||||
);
|
||||
assert!(
|
||||
!body_str.contains("Success!"),
|
||||
"{body_str}\nexpected not to contain Success!"
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_basic_auth_with_credentials() {
|
||||
let req = get_request_to("/tests/errors/basic_auth.sql")
|
||||
.await
|
||||
.unwrap() // log in with credentials "user:password"
|
||||
.append_header(("Authorization", "Basic dXNlcjpwYXNzd29yZA=="))
|
||||
.to_srv_request();
|
||||
let resp = main_handler(req)
|
||||
.await
|
||||
.expect("req with credentials should succeed");
|
||||
assert_eq!(resp.status(), StatusCode::OK);
|
||||
let body = test::read_body(resp).await;
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(
|
||||
body_str.contains("Success!"),
|
||||
"{body_str}\nexpected to contain Success"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
SELECT 'authentication' AS component,
|
||||
'$argon2i$v=19$m=8,t=1,p=1$YWFhYWFhYWE$oKBq5E8XFTHO2w' AS password_hash, -- this is a hash of the password 'password'
|
||||
sqlpage.basic_auth_password() AS password; -- this is the password that the user entered in the browser popup
|
||||
|
||||
SELECT 'text' AS component, 'Success!' AS contents;
|
||||
@@ -0,0 +1,108 @@
|
||||
use crate::common::req_path;
|
||||
use actix_web::{http::StatusCode, test};
|
||||
use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
struct InvalidHeaderCase {
|
||||
name: &'static str,
|
||||
properties: Value,
|
||||
}
|
||||
|
||||
async fn assert_invalid_header_response(case: &InvalidHeaderCase) {
|
||||
let properties = serde_json::to_string(&case.properties).unwrap();
|
||||
let properties = utf8_percent_encode(&properties, NON_ALPHANUMERIC).to_string();
|
||||
let path = format!("/tests/errors/invalid_header.sql?properties={properties}");
|
||||
|
||||
let resp = req_path(&path).await.unwrap_or_else(|err| {
|
||||
panic!(
|
||||
"{} should return an error response instead of failing the request: {err:#}",
|
||||
case.name
|
||||
)
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
resp.status(),
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"{} should return a 500 response",
|
||||
case.name
|
||||
);
|
||||
|
||||
let body = test::read_body(resp).await;
|
||||
let body_str = String::from_utf8(body.to_vec()).unwrap();
|
||||
assert!(
|
||||
body_str.to_lowercase().contains("error"),
|
||||
"{} should render an error response body, got:\n{body_str}",
|
||||
case.name
|
||||
);
|
||||
}
|
||||
|
||||
#[actix_web::test]
|
||||
async fn test_invalid_header_components_return_an_error_response() {
|
||||
let cases = vec![
|
||||
InvalidHeaderCase {
|
||||
name: "cookie domain with newline",
|
||||
properties: json!({
|
||||
"component": "cookie",
|
||||
"name": "boom",
|
||||
"value": "x",
|
||||
"domain": "\n",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "cookie path with DEL",
|
||||
properties: json!({
|
||||
"component": "cookie",
|
||||
"name": "boom",
|
||||
"value": "x",
|
||||
"path": "\u{007f}",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "http_header value with carriage return",
|
||||
properties: json!({
|
||||
"component": "http_header",
|
||||
"X-Test": "\r",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "redirect link with NUL",
|
||||
properties: json!({
|
||||
"component": "redirect",
|
||||
"link": "\u{0000}",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "authentication link with unit separator",
|
||||
properties: json!({
|
||||
"component": "authentication",
|
||||
"link": "\u{001f}",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "download filename with newline",
|
||||
properties: json!({
|
||||
"component": "download",
|
||||
"data_url": "data:text/plain,ok",
|
||||
"filename": "\n",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "csv filename with carriage return",
|
||||
properties: json!({
|
||||
"component": "csv",
|
||||
"filename": "\r",
|
||||
}),
|
||||
},
|
||||
InvalidHeaderCase {
|
||||
name: "csv title with NUL",
|
||||
properties: json!({
|
||||
"component": "csv",
|
||||
"title": "\u{0000}",
|
||||
}),
|
||||
},
|
||||
];
|
||||
|
||||
for case in &cases {
|
||||
assert_invalid_header_response(case).await;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
select 'dynamic' as component, $properties as properties;
|
||||
@@ -0,0 +1,175 @@
|
||||
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"));
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Trusted page: loads a reserved (sqlpage/) file via the privileged run_sql,
|
||||
-- which populates the shared sql_file_cache with the parsed private file.
|
||||
select 'dynamic' as component,
|
||||
sqlpage.run_sql('sqlpage/private_cache_bypass_test.sql') as properties;
|
||||
Reference in New Issue
Block a user