Skip to content

Commit d92a739

Browse files
authored
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.
1 parent 895096b commit d92a739

7 files changed

Lines changed: 118 additions & 29 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
# CHANGELOG.md
22

3+
## Unreleased
4+
5+
- **Security fix: reserved and private files could be served directly over HTTP after a trusted page loaded them.** Files that SQLPage normally refuses to serve to direct HTTP requests (anything under the reserved `sqlpage/` prefix such as migrations, configuration, and database connection details, as well as dotfiles, parent-directory traversal paths like `../secret.sql`, and absolute paths) could briefly become reachable. This happened only after a trusted page loaded that same file with `sqlpage.run_sql(...)`, which loads files with elevated privileges and caches the parsed result. While that cache entry was fresh, a direct request such as `GET /sqlpage/secret.sql` (or the extensionless alias `GET /sqlpage/secret`) returned `200 OK` and executed the private SQL instead of returning `403 Forbidden`. Worst case, an attacker who can reach your site could read or execute internal SQL that was meant to stay private, including migration and configuration logic. You are affected if your app calls `sqlpage.run_sql()` on files inside `sqlpage/` (or on dotfiles or paths outside the web root) and is reachable by untrusted users. The fix enforces the unprivileged path guard on every direct HTTP request regardless of the cache, so these paths now always return `403 Forbidden`. Upgrade to get the fix; no configuration change is required. Legitimate `sqlpage.run_sql()` includes of such files from your own pages keep working as before.
6+
37
## v0.44.0
48

59
This release focuses on making production SQLPage apps easier to understand, debug, and operate. Most apps should keep working without SQL changes, but maintainers should review the notes about logging and uploaded-file permissions.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 'text' as component, 'private cache bypass secret' as contents;

src/file_cache.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,12 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
120120
privileged: bool,
121121
) -> anyhow::Result<Arc<T>> {
122122
log::trace!("Attempting to get from cache {}", path.display());
123+
// Enforce the untrusted path guard before consulting the cache. A fresh cache
124+
// entry (possibly populated by a privileged `run_sql` include) must never let an
125+
// unprivileged request reach a reserved/private/traversal/absolute path.
126+
if !privileged {
127+
crate::filesystem::validate_unprivileged_path(path)?;
128+
}
123129
if let Some(cached) = self.cache.read().await.get(path) {
124130
if !cached.needs_check(app_state.config.cache_stale_duration_ms()) {
125131
log::trace!(

src/filesystem.rs

Lines changed: 42 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -153,34 +153,7 @@ impl FileSystem {
153153
return Ok(normalized);
154154
}
155155
} else {
156-
for (i, component) in path.components().enumerate() {
157-
if let Component::Normal(c) = component {
158-
if i == 0 && c.eq_ignore_ascii_case("sqlpage") {
159-
return Err(ErrorWithStatus {
160-
status: actix_web::http::StatusCode::FORBIDDEN,
161-
})
162-
.with_context(|| {
163-
"The /sqlpage/ path prefix is reserved for internal use. It is not public."
164-
});
165-
}
166-
if c.as_encoded_bytes().starts_with(b".") {
167-
return Err(ErrorWithStatus {
168-
status: actix_web::http::StatusCode::FORBIDDEN,
169-
})
170-
.with_context(|| "Directory traversal is not allowed");
171-
}
172-
} else {
173-
return Err(ErrorWithStatus {
174-
status: actix_web::http::StatusCode::FORBIDDEN,
175-
})
176-
.with_context(|| {
177-
format!(
178-
"Unsupported path: {}. Path component '{component:?}' is not allowed.",
179-
path.display()
180-
)
181-
});
182-
}
183-
}
156+
validate_unprivileged_path(path)?;
184157
}
185158
Ok(self.local_root.join(path))
186159
}
@@ -219,6 +192,47 @@ impl FileSystem {
219192
}
220193
}
221194

195+
/// Rejects paths that an untrusted (unprivileged) HTTP request must never reach:
196+
/// the reserved `sqlpage/` prefix, dotfiles, parent-directory traversal and
197+
/// absolute/root paths.
198+
///
199+
/// This guard must be enforced on every unprivileged resolution path, including
200+
/// before trusting a fresh cache hit. A trusted page may legitimately load such a
201+
/// file with privilege via `sqlpage.run_sql(...)`, which populates the shared file
202+
/// cache; without this check a later direct HTTP request could be served the cached
203+
/// private file instead of being forbidden.
204+
pub fn validate_unprivileged_path(path: &Path) -> anyhow::Result<()> {
205+
for (i, component) in path.components().enumerate() {
206+
if let Component::Normal(c) = component {
207+
if i == 0 && c.eq_ignore_ascii_case("sqlpage") {
208+
return Err(ErrorWithStatus {
209+
status: actix_web::http::StatusCode::FORBIDDEN,
210+
})
211+
.with_context(
212+
|| "The /sqlpage/ path prefix is reserved for internal use. It is not public.",
213+
);
214+
}
215+
if c.as_encoded_bytes().starts_with(b".") {
216+
return Err(ErrorWithStatus {
217+
status: actix_web::http::StatusCode::FORBIDDEN,
218+
})
219+
.with_context(|| "Directory traversal is not allowed");
220+
}
221+
} else {
222+
return Err(ErrorWithStatus {
223+
status: actix_web::http::StatusCode::FORBIDDEN,
224+
})
225+
.with_context(|| {
226+
format!(
227+
"Unsupported path: {}. Path component '{component:?}' is not allowed.",
228+
path.display()
229+
)
230+
});
231+
}
232+
}
233+
Ok(())
234+
}
235+
222236
fn is_path_missing_error(error: &std::io::Error) -> bool {
223237
matches!(error.kind(), ErrorKind::NotFound | ErrorKind::NotADirectory)
224238
}

src/webserver/routing.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,10 @@ impl<'a> AppFileStore<'a> {
130130

131131
impl FileStore for AppFileStore<'_> {
132132
async fn contains(&self, path: &Path) -> anyhow::Result<bool> {
133+
// HTTP routing is always an untrusted, unprivileged operation. Enforce the path
134+
// guard before consulting the cache: a fresh cache entry populated by a
135+
// privileged `run_sql` include must not make a reserved/private path routable.
136+
crate::filesystem::validate_unprivileged_path(path)?;
133137
if self.cache.contains(path).await? {
134138
Ok(true)
135139
} else {

tests/errors/mod.rs

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,67 @@ use actix_web::{
22
http::{self, StatusCode},
33
test,
44
};
5+
use sqlpage::{AppState, webserver::http::main_handler};
56

6-
use crate::common::req_path;
7+
use crate::common::{make_app_data_from_config, req_path, req_path_with_app_data, test_config};
78
mod basic_auth;
89
mod invalid_header;
910

11+
/// Sends a direct unprivileged GET request through the main handler and returns the
12+
/// resulting HTTP status, whether the handler returned an `Ok` response or an `Err`.
13+
async fn direct_request_status(path: &str, app_data: actix_web::web::Data<AppState>) -> StatusCode {
14+
let req = test::TestRequest::get()
15+
.uri(path)
16+
.app_data(app_data)
17+
.insert_header(actix_web::http::header::Accept::html())
18+
.to_srv_request();
19+
match main_handler(req).await {
20+
Ok(resp) => resp.status(),
21+
Err(e) => e.error_response().status(),
22+
}
23+
}
24+
25+
/// Regression test for a cache privilege-escalation bug.
26+
///
27+
/// `sqlpage.run_sql(...)` loads include files with privilege, so it is allowed to
28+
/// load reserved files under the `sqlpage/` prefix and stores their parsed form in
29+
/// the shared `sql_file_cache`. A subsequent *direct* unprivileged HTTP request for
30+
/// that same reserved path must still be rejected with 403, even while the cache
31+
/// entry is fresh. Before the fix, the fresh cache hit short-circuited the
32+
/// unprivileged path guard and the private SQL was executed and served.
33+
#[actix_web::test]
34+
async fn test_private_path_not_accessible_after_privileged_cache_priming() {
35+
// Keep cache entries "fresh" so the bug (skipping the path guard on fresh hits) is exercised.
36+
let mut config = test_config();
37+
config.cache_stale_duration_ms = Some(60_000);
38+
let app_data = make_app_data_from_config(config).await;
39+
40+
// 1. A trusted page primes the cache by loading the reserved file with privilege.
41+
let prime = req_path_with_app_data("/tests/errors/prime_private_cache.sql", app_data.clone())
42+
.await
43+
.expect("priming page should render");
44+
assert_eq!(prime.status(), StatusCode::OK);
45+
let prime_body = String::from_utf8(test::read_body(prime).await.to_vec()).unwrap();
46+
assert!(
47+
prime_body.contains("private cache bypass secret"),
48+
"priming page should have executed the private file via run_sql, got: {prime_body}"
49+
);
50+
51+
// 2. A direct unprivileged HTTP request for the reserved path must stay forbidden.
52+
for path in [
53+
"/sqlpage/private_cache_bypass_test.sql",
54+
// Extensionless alias: routing appends .sql and finds the fresh cache entry.
55+
"/sqlpage/private_cache_bypass_test",
56+
] {
57+
let status = direct_request_status(path, app_data.clone()).await;
58+
assert_eq!(
59+
status,
60+
StatusCode::FORBIDDEN,
61+
"{path} must be forbidden even after privileged cache priming, got {status}"
62+
);
63+
}
64+
}
65+
1066
#[actix_web::test]
1167
async fn test_privileged_paths_are_not_accessible() {
1268
let resp_result = req_path("/sqlpage/migrations/0001_init.sql").await;
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
-- Trusted page: loads a reserved (sqlpage/) file via the privileged run_sql,
2+
-- which populates the shared sql_file_cache with the parsed private file.
3+
select 'dynamic' as component,
4+
sqlpage.run_sql('sqlpage/private_cache_bypass_test.sql') as properties;

0 commit comments

Comments
 (0)