Skip to content

Commit 0b24f2f

Browse files
committed
fix: hide SQL details in production JSON/NDJSON/SSE/CSV error responses
In production mode the HTML renderer already replaced database errors with a generic message, but the JSON, NDJSON, SSE, and CSV renderers serialized the full error into the response body after streaming started. That error included the source .sql file path, the exact SQL statement, and the raw database error text. Since the response format is selected from the Accept header, an unauthenticated client could request an HTML page with Accept: application/json and read back its SQL. Thread the production flag into JsonBodyRenderer and CsvBodyRenderer and emit the same generic message HTML uses. The full error is still logged server-side, and detailed errors are still shown in development.
1 parent fe13e4a commit 0b24f2f

6 files changed

Lines changed: 153 additions & 17 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
- **Download filenames can no longer inject extra `Content-Disposition` parameters.** The `csv` and `download` components now build the `Content-Disposition` header with a properly quoted and escaped filename instead of plain string concatenation. Before this fix, a filename containing characters such as `;`, `"`, or `=` could add a second header parameter (for example a `filename*=...` value), and some browsers prefer that injected value over the intended one. You are affected if your app puts untrusted data (such as a user-provided name or a value from the database) into the `filename` of a `csv` or `download` component. No SQL change is required: the supplied filename now always appears as a single, safely quoted `filename` value.
66
- **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.
7+
- **Security: production JSON, NDJSON, SSE, and CSV responses no longer leak SQL on errors.** When `environment` is set to `production`, the HTML renderer already hid database errors behind a generic message, but the JSON, NDJSON, Server-Sent Events, and CSV renderers still wrote the full error into the response body once streaming had started. That error included the path of the `.sql` file, the exact SQL statement SQLPage sent to the database, and the raw database error text. Because the response format is chosen from the request's `Accept` header, an unauthenticated visitor could request an ordinary HTML page with `Accept: application/json` and read back the SQL of pages that were never meant to expose it. You are affected if you run with `environment = production` and serve any page that can hit a database error (most apps can). There is nothing to change in your SQL: after upgrading, these formats return the same generic "Please contact the administrator for more information. The error has been logged." message that HTML already used, and the full error is still written to the server logs. In development the detailed error is still shown.
78

89
## v0.44.0
910

src/render.rs

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,22 @@ use std::path::Path;
6767
use std::str::FromStr;
6868
use std::sync::Arc;
6969

70+
/// Generic message shown to end users in production instead of the detailed
71+
/// error, which would leak the source file path, the SQL statement, and the
72+
/// raw database error text.
73+
const PRODUCTION_ERROR_MESSAGE: &str =
74+
"Please contact the administrator for more information. The error has been logged.";
75+
76+
/// Returns the message to display for an error, hiding the details in
77+
/// production so they are not leaked to the client.
78+
fn error_message(error: &anyhow::Error, is_prod: bool) -> String {
79+
if is_prod {
80+
PRODUCTION_ERROR_MESSAGE.to_string()
81+
} else {
82+
error.to_string()
83+
}
84+
}
85+
7086
pub enum PageContext {
7187
/// Indicates that we should stay in the header context
7288
Header(HeaderContext),
@@ -276,13 +292,14 @@ impl HeaderContext {
276292
};
277293
self.close_with_body(json_response)
278294
} else {
295+
let is_prod = self.app_state.config.environment.is_prod();
279296
let body_type = get_object_str(data, "type");
280297
let json_renderer = match body_type {
281-
None | Some("array") => JsonBodyRenderer::new_array(self.writer),
282-
Some("jsonlines") => JsonBodyRenderer::new_jsonlines(self.writer),
298+
None | Some("array") => JsonBodyRenderer::new_array(self.writer, is_prod),
299+
Some("jsonlines") => JsonBodyRenderer::new_jsonlines(self.writer, is_prod),
283300
Some("sse") => {
284301
self.insert_header((header::CONTENT_TYPE, "text/event-stream"))?;
285-
JsonBodyRenderer::new_server_sent_events(self.writer)
302+
JsonBodyRenderer::new_server_sent_events(self.writer, is_prod)
286303
}
287304
_ => bail!(
288305
"Invalid value for the 'type' property of the json component: {body_type:?}"
@@ -305,7 +322,8 @@ impl HeaderContext {
305322
let extension = if filename.contains('.') { "" } else { ".csv" };
306323
self.insert_header(attachment_with_filename(&format!("{filename}{extension}")))?;
307324
}
308-
let csv_renderer = CsvBodyRenderer::new(self.writer, options).await?;
325+
let is_prod = self.app_state.config.environment.is_prod();
326+
let csv_renderer = CsvBodyRenderer::new(self.writer, is_prod, options).await?;
309327
let renderer = AnyRenderBodyContext::Csv(csv_renderer);
310328
let http_response = self.response.take();
311329
Ok(PageContext::Body {
@@ -396,12 +414,13 @@ impl HeaderContext {
396414

397415
async fn start_body(mut self, data: JsonValue) -> anyhow::Result<PageContext> {
398416
self.add_server_timing_header()?;
417+
let is_prod = self.app_state.config.environment.is_prod();
399418
let renderer = match self.request_context.response_format {
400419
ResponseFormat::Json => AnyRenderBodyContext::Json(
401-
JsonBodyRenderer::new_array_with_first_row(self.writer, &data),
420+
JsonBodyRenderer::new_array_with_first_row(self.writer, is_prod, &data),
402421
),
403422
ResponseFormat::JsonLines => AnyRenderBodyContext::Json(
404-
JsonBodyRenderer::new_jsonlines_with_first_row(self.writer, &data),
423+
JsonBodyRenderer::new_jsonlines_with_first_row(self.writer, is_prod, &data),
405424
),
406425
ResponseFormat::Html => {
407426
let html_renderer =
@@ -528,48 +547,60 @@ impl AnyRenderBodyContext {
528547
pub struct JsonBodyRenderer<W: std::io::Write> {
529548
writer: W,
530549
is_first: bool,
550+
is_prod: bool,
531551
prefix: &'static [u8],
532552
suffix: &'static [u8],
533553
separator: &'static [u8],
534554
}
535555

536556
impl<W: std::io::Write> JsonBodyRenderer<W> {
537-
pub fn new_array(writer: W) -> JsonBodyRenderer<W> {
557+
pub fn new_array(writer: W, is_prod: bool) -> JsonBodyRenderer<W> {
538558
let mut renderer = Self {
539559
writer,
540560
is_first: true,
561+
is_prod,
541562
prefix: b"[\n",
542563
suffix: b"\n]",
543564
separator: b",\n",
544565
};
545566
let _ = renderer.write_prefix();
546567
renderer
547568
}
548-
pub fn new_array_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer<W> {
549-
let mut renderer = Self::new_array(writer);
569+
pub fn new_array_with_first_row(
570+
writer: W,
571+
is_prod: bool,
572+
first_row: &JsonValue,
573+
) -> JsonBodyRenderer<W> {
574+
let mut renderer = Self::new_array(writer, is_prod);
550575
let _ = renderer.handle_row(first_row);
551576
renderer
552577
}
553-
pub fn new_jsonlines(writer: W) -> JsonBodyRenderer<W> {
578+
pub fn new_jsonlines(writer: W, is_prod: bool) -> JsonBodyRenderer<W> {
554579
let mut renderer = Self {
555580
writer,
556581
is_first: true,
582+
is_prod,
557583
prefix: b"",
558584
suffix: b"",
559585
separator: b"\n",
560586
};
561587
renderer.write_prefix().unwrap();
562588
renderer
563589
}
564-
pub fn new_jsonlines_with_first_row(writer: W, first_row: &JsonValue) -> JsonBodyRenderer<W> {
565-
let mut renderer = Self::new_jsonlines(writer);
590+
pub fn new_jsonlines_with_first_row(
591+
writer: W,
592+
is_prod: bool,
593+
first_row: &JsonValue,
594+
) -> JsonBodyRenderer<W> {
595+
let mut renderer = Self::new_jsonlines(writer, is_prod);
566596
let _ = renderer.handle_row(first_row);
567597
renderer
568598
}
569-
pub fn new_server_sent_events(writer: W) -> JsonBodyRenderer<W> {
599+
pub fn new_server_sent_events(writer: W, is_prod: bool) -> JsonBodyRenderer<W> {
570600
let mut renderer = Self {
571601
writer,
572602
is_first: true,
603+
is_prod,
573604
prefix: b"data: ",
574605
suffix: b"\n\n",
575606
separator: b"\n\ndata: ",
@@ -592,7 +623,7 @@ impl<W: std::io::Write> JsonBodyRenderer<W> {
592623
}
593624
pub fn handle_error(&mut self, error: &anyhow::Error) -> anyhow::Result<()> {
594625
self.handle_row(&json!({
595-
"error": error.to_string()
626+
"error": error_message(error, self.is_prod)
596627
}))
597628
}
598629

@@ -606,11 +637,13 @@ pub struct CsvBodyRenderer {
606637
// The writer is a large struct, so we store it on the heap
607638
writer: Box<csv_async::AsyncWriter<AsyncResponseWriter>>,
608639
columns: Vec<String>,
640+
is_prod: bool,
609641
}
610642

611643
impl CsvBodyRenderer {
612644
pub async fn new(
613645
mut writer: ResponseWriter,
646+
is_prod: bool,
614647
options: &JsonValue,
615648
) -> anyhow::Result<CsvBodyRenderer> {
616649
let mut builder = csv_async::AsyncWriterBuilder::new();
@@ -646,6 +679,7 @@ impl CsvBodyRenderer {
646679
Ok(CsvBodyRenderer {
647680
writer: Box::new(writer),
648681
columns: vec![],
682+
is_prod,
649683
})
650684
}
651685

@@ -678,13 +712,13 @@ impl CsvBodyRenderer {
678712
}
679713

680714
pub async fn handle_error(&mut self, error: &anyhow::Error) -> anyhow::Result<()> {
681-
let err_str = error.to_string();
715+
let err_str = error_message(error, self.is_prod);
682716
self.writer
683717
.write_record(
684718
self.columns
685719
.iter()
686720
.enumerate()
687-
.map(|(i, _)| if i == 0 { &err_str } else { "" })
721+
.map(|(i, _)| if i == 0 { err_str.as_str() } else { "" })
688722
.collect::<Vec<_>>(),
689723
)
690724
.await?;
@@ -854,7 +888,7 @@ impl<W: std::io::Write> HtmlRenderContext<W> {
854888
self.close_component()?;
855889
let data = if self.app_state.config.environment.is_prod() {
856890
json!({
857-
"description": format!("Please contact the administrator for more information. The error has been logged."),
891+
"description": PRODUCTION_ERROR_MESSAGE,
858892
})
859893
} else {
860894
json!({
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
select 'csv' as component;
2+
select 0 as id, 'before the error' as msg;
3+
select * from definitely_missing_table_xyz;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
select 'json' as component;
2+
select 'before the error' as message;
3+
select * from definitely_missing_table_xyz;

tests/data_formats/mod.rs

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -231,3 +231,96 @@ async fn test_accept_json_redirect_still_works() -> actix_web::Result<()> {
231231
);
232232
Ok(())
233233
}
234+
235+
/// Builds an `AppState` running in production mode.
236+
async fn make_prod_app_data() -> actix_web::web::Data<sqlpage::AppState> {
237+
crate::common::init_log();
238+
let mut config = crate::common::test_config();
239+
config.environment = sqlpage::app_config::DevOrProd::Production;
240+
crate::common::make_app_data_from_config(config).await
241+
}
242+
243+
async fn req_prod_with_accept(path: &str, accept: &str) -> String {
244+
let app_data = make_prod_app_data().await;
245+
let req = TestRequest::get()
246+
.uri(path)
247+
.insert_header((header::ACCEPT, accept))
248+
.app_data(app_data)
249+
.to_srv_request();
250+
let resp = main_handler(req)
251+
.await
252+
.expect("request should not fail at the handler level");
253+
let body = test::read_body(resp).await;
254+
String::from_utf8(body.to_vec()).unwrap()
255+
}
256+
257+
/// In production, a SQL error that happens mid-stream must not leak the SQL
258+
/// statement, the source file path, or the raw database error text.
259+
fn assert_no_sql_leak(body: &str, context: &str) {
260+
for needle in [
261+
"definitely_missing_table_xyz",
262+
"The SQL statement sent by SQLPage",
263+
"error_leak.sql",
264+
".sql\"",
265+
] {
266+
assert!(
267+
!body.contains(needle),
268+
"production error response leaked {needle:?} in {context}: {body}"
269+
);
270+
}
271+
assert!(
272+
body.to_lowercase().contains("administrator"),
273+
"production error response should contain a generic message in {context}: {body}"
274+
);
275+
}
276+
277+
#[actix_web::test]
278+
async fn test_prod_json_error_does_not_leak_sql() {
279+
let body = req_prod_with_accept(
280+
"/tests/data_formats/json_error_leak.sql",
281+
"application/json",
282+
)
283+
.await;
284+
assert!(
285+
body.contains("before the error"),
286+
"the good row should still be streamed: {body}"
287+
);
288+
assert_no_sql_leak(&body, "json error");
289+
}
290+
291+
#[actix_web::test]
292+
async fn test_prod_csv_error_does_not_leak_sql() {
293+
let app_data = make_prod_app_data().await;
294+
if matches!(
295+
app_data.db.info.database_type,
296+
sqlpage::webserver::database::SupportedDatabase::Oracle
297+
) {
298+
return;
299+
}
300+
let req = TestRequest::get()
301+
.uri("/tests/data_formats/csv_error_leak.sql")
302+
.insert_header((header::ACCEPT, "text/csv"))
303+
.app_data(app_data)
304+
.to_srv_request();
305+
let resp = main_handler(req).await.expect("handler should not fail");
306+
let body = test::read_body(resp).await;
307+
let body = String::from_utf8(body.to_vec()).unwrap();
308+
assert!(
309+
body.contains("before the error"),
310+
"the good row should still be streamed: {body}"
311+
);
312+
assert_no_sql_leak(&body, "csv error");
313+
}
314+
315+
/// An author may only intend a page to be served as HTML, but a client can
316+
/// request it with `Accept: application/json` and pick the JSON renderer.
317+
/// In production that path must not leak SQL text either.
318+
#[actix_web::test]
319+
async fn test_prod_html_page_requested_as_json_does_not_leak_sql() {
320+
let body = req_prod_with_accept(
321+
"/tests/data_formats/text_error_leak.sql",
322+
"application/json",
323+
)
324+
.await;
325+
assert_no_sql_leak(&body, "text page requested as json");
326+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
select 'text' as component, 'before the error' as contents;
2+
select * from definitely_missing_table_xyz;

0 commit comments

Comments
 (0)