Skip to content

Commit 74bb46e

Browse files
committed
Fix http header only page crashes
Return errors early on invalid header values
1 parent 316fa4b commit 74bb46e

File tree

6 files changed

+146
-28
lines changed

6 files changed

+146
-28
lines changed

CHANGELOG.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,9 @@
33
## unreleased
44

55
- Made OIDC and `sqlpage.fetch` debug logs safer and simpler by removing raw token, cookie, claims, and response body dumps while keeping useful request and response metadata.
6-
- Fixed a bug where the single-sign-on oidc code would generate an unbounded amount of cookies when receiving many unauthenticated requests in sequence.
6+
- Fixed a bug where the single-sign-on oidc code would generate an unbounded amount of cookies when receiving many unauthenticated requests in sequence.
77
- Fixed multiple incorrect or imprecise HTTP statuses returned by sqlpage on error
8-
- this makes it easier for an administrator to distinguish between user errors (4xx, non actionnable) and server errors (5xx, when you see them you should do something)
8+
- this makes it easier for an administrator to distinguish between user errors (4xx, non actionnable) and server errors (5xx, when you see them you should do something)
99
- for instance: invalid UTF-8 in multipart text fields now returns `400 Bad Request` instead of `500 Internal Server Error`.
1010
- Logging: `LOG_LEVEL` is now the primary environment variable for configuring SQLPage's log filter. `RUST_LOG` remains supported as an alias.
1111
- You can now easily understand and debug slow page loads thanks to the added support for [OpenTelemetry](https://opentelemetry.io) tracing & metrics
@@ -15,6 +15,7 @@
1515
- Added an argument to `sqlpage.persist_uploaded_file(...)` to control the permissions of the newly created file.
1616
- Notably, this makes it easier to accelerate serving of uploaded files by letting a reverse proxy like nginx serve them directly.
1717
- Added an [`id` row-level parameter to the datagrid component](https://github.com/sqlpage/SQLPage/issues/1243)
18+
- Fixed crashes when a header-only page returned an http header containing an invalid character
1819

1920
## 0.43.0
2021

src/render.rs

Lines changed: 32 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ impl HeaderContext {
158158
Ok(self)
159159
}
160160

161+
fn insert_header(&mut self, header: impl TryIntoHeaderPair) -> anyhow::Result<()> {
162+
let pair = header.try_into_pair().map_err(Into::into)?;
163+
self.response.insert_header(pair);
164+
Ok(())
165+
}
166+
167+
fn append_header(&mut self, header: impl TryIntoHeaderPair) -> anyhow::Result<()> {
168+
let pair = header.try_into_pair().map_err(Into::into)?;
169+
self.response.append_header(pair);
170+
Ok(())
171+
}
172+
161173
fn add_http_header(mut self, data: &JsonValue) -> anyhow::Result<Self> {
162174
let obj = data.as_object().with_context(|| "expected object")?;
163175
for (name, value) in obj {
@@ -173,7 +185,7 @@ impl HeaderContext {
173185
}
174186
let header = TryIntoHeaderPair::try_into_pair((name.as_str(), value_str))
175187
.map_err(|e| anyhow::anyhow!("Invalid header: {name}:{value_str}: {e:#?}"))?;
176-
self.response.insert_header(header);
188+
self.insert_header(header)?;
177189
}
178190
Ok(self)
179191
}
@@ -238,8 +250,7 @@ impl HeaderContext {
238250
}));
239251
}
240252
log::trace!("Setting cookie {cookie}");
241-
self.response
242-
.append_header((header::SET_COOKIE, cookie.encoded().to_string()));
253+
self.append_header((header::SET_COOKIE, cookie.encoded().to_string()))?;
243254
Ok(self)
244255
}
245256

@@ -248,14 +259,13 @@ impl HeaderContext {
248259
self.has_status = true;
249260
let link = get_object_str(data, "link")
250261
.with_context(|| "The redirect component requires a 'link' property")?;
251-
self.response.insert_header((header::LOCATION, link));
262+
self.insert_header((header::LOCATION, link))?;
252263
self.close_with_body(())
253264
}
254265

255266
/// Answers to the HTTP request with a single json object
256267
fn json(mut self, data: &JsonValue) -> anyhow::Result<PageContext> {
257-
self.response
258-
.insert_header((header::CONTENT_TYPE, "application/json"));
268+
self.insert_header((header::CONTENT_TYPE, "application/json"))?;
259269
if let Some(contents) = data.get("contents") {
260270
let json_response = if let Some(s) = contents.as_str() {
261271
s.as_bytes().to_owned()
@@ -269,8 +279,7 @@ impl HeaderContext {
269279
None | Some("array") => JsonBodyRenderer::new_array(self.writer),
270280
Some("jsonlines") => JsonBodyRenderer::new_jsonlines(self.writer),
271281
Some("sse") => {
272-
self.response
273-
.insert_header((header::CONTENT_TYPE, "text/event-stream"));
282+
self.insert_header((header::CONTENT_TYPE, "text/event-stream"))?;
274283
JsonBodyRenderer::new_server_sent_events(self.writer)
275284
}
276285
_ => bail!(
@@ -287,16 +296,15 @@ impl HeaderContext {
287296
}
288297

289298
async fn csv(mut self, options: &JsonValue) -> anyhow::Result<PageContext> {
290-
self.response
291-
.insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8"));
299+
self.insert_header((header::CONTENT_TYPE, "text/csv; charset=utf-8"))?;
292300
if let Some(filename) =
293301
get_object_str(options, "filename").or_else(|| get_object_str(options, "title"))
294302
{
295303
let extension = if filename.contains('.') { "" } else { ".csv" };
296-
self.response.insert_header((
304+
self.insert_header((
297305
header::CONTENT_DISPOSITION,
298306
format!("attachment; filename={filename}{extension}"),
299-
));
307+
))?;
300308
}
301309
let csv_renderer = CsvBodyRenderer::new(self.writer, options).await?;
302310
let renderer = AnyRenderBodyContext::Csv(csv_renderer);
@@ -320,10 +328,9 @@ impl HeaderContext {
320328
log::debug!("Authentication failed");
321329
// The authentication failed
322330
if let Some(link) = get_object_str(&data, "link") {
323-
self.response
324-
.status(StatusCode::FOUND)
325-
.insert_header((header::LOCATION, link));
331+
self.response.status(StatusCode::FOUND);
326332
self.has_status = true;
333+
self.insert_header((header::LOCATION, link))?;
327334
let response = self.into_response(
328335
"Sorry, but you are not authorized to access this page. \
329336
Redirecting to the login page...",
@@ -338,10 +345,10 @@ impl HeaderContext {
338345

339346
fn download(mut self, options: &JsonValue) -> anyhow::Result<PageContext> {
340347
if let Some(filename) = get_object_str(options, "filename") {
341-
self.response.insert_header((
348+
self.insert_header((
342349
header::CONTENT_DISPOSITION,
343350
format!("attachment; filename=\"{filename}\""),
344-
));
351+
))?;
345352
}
346353
let data_url = get_object_str(options, "data_url")
347354
.with_context(|| "The download component requires a 'data_url' property")?;
@@ -360,8 +367,7 @@ impl HeaderContext {
360367
.into();
361368
}
362369
if !content_type.is_empty() {
363-
self.response
364-
.insert_header((header::CONTENT_TYPE, content_type));
370+
self.insert_header((header::CONTENT_TYPE, content_type))?;
365371
}
366372
self.close_with_body(body_bytes.into_owned())
367373
}
@@ -371,14 +377,15 @@ impl HeaderContext {
371377
Ok(PageContext::Header(self))
372378
}
373379

374-
fn add_server_timing_header(&mut self) {
380+
fn add_server_timing_header(&mut self) -> anyhow::Result<()> {
375381
if let Some(header_value) = self.request_context.server_timing.header_value() {
376-
self.response.insert_header(("Server-Timing", header_value));
382+
self.insert_header(("Server-Timing", header_value))?;
377383
}
384+
Ok(())
378385
}
379386

380387
fn into_response<B: MessageBody + 'static>(mut self, body: B) -> anyhow::Result<HttpResponse> {
381-
self.add_server_timing_header();
388+
self.add_server_timing_header()?;
382389
match self.response.message_body(body) {
383390
Ok(response) => Ok(response.map_into_boxed_body()),
384391
Err(e) => Err(anyhow::anyhow!(
@@ -392,7 +399,7 @@ impl HeaderContext {
392399
}
393400

394401
async fn start_body(mut self, data: JsonValue) -> anyhow::Result<PageContext> {
395-
self.add_server_timing_header();
402+
self.add_server_timing_header()?;
396403
let renderer = match self.request_context.response_format {
397404
ResponseFormat::Json => AnyRenderBodyContext::Json(
398405
JsonBodyRenderer::new_array_with_first_row(self.writer, &data),
@@ -417,8 +424,8 @@ impl HeaderContext {
417424
})
418425
}
419426

420-
pub fn close(self) -> HttpResponse {
421-
self.into_response(()).unwrap()
427+
pub fn close(self) -> anyhow::Result<HttpResponse> {
428+
self.into_response(())
422429
}
423430
}
424431

src/webserver/http.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ async fn build_response_header_and_stream<S: Stream<Item = DbItem>>(
190190
}
191191
}
192192
log::debug!("No SQL statements left to execute for the body of the response");
193-
let http_response = head_context.close();
193+
let http_response = head_context.close()?;
194194
Ok(ResponseWithWriter::FinishedResponse { http_response })
195195
}
196196

tests/errors/invalid_header.rs

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
use crate::common::req_path;
2+
use actix_web::{http::StatusCode, test};
3+
use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC};
4+
use serde_json::{json, Value};
5+
6+
struct InvalidHeaderCase {
7+
name: &'static str,
8+
properties: Value,
9+
}
10+
11+
async fn assert_invalid_header_response(case: &InvalidHeaderCase) {
12+
let properties = serde_json::to_string(&case.properties).unwrap();
13+
let properties = utf8_percent_encode(&properties, NON_ALPHANUMERIC).to_string();
14+
let path = format!("/tests/errors/invalid_header.sql?properties={properties}");
15+
16+
let resp = req_path(&path).await.unwrap_or_else(|err| {
17+
panic!(
18+
"{} should return an error response instead of failing the request: {err:#}",
19+
case.name
20+
)
21+
});
22+
23+
assert_eq!(
24+
resp.status(),
25+
StatusCode::INTERNAL_SERVER_ERROR,
26+
"{} should return a 500 response",
27+
case.name
28+
);
29+
30+
let body = test::read_body(resp).await;
31+
let body_str = String::from_utf8(body.to_vec()).unwrap();
32+
assert!(
33+
body_str.to_lowercase().contains("error"),
34+
"{} should render an error response body, got:\n{body_str}",
35+
case.name
36+
);
37+
}
38+
39+
#[actix_web::test]
40+
async fn test_invalid_header_components_return_an_error_response() {
41+
let cases = vec![
42+
InvalidHeaderCase {
43+
name: "cookie domain with newline",
44+
properties: json!({
45+
"component": "cookie",
46+
"name": "boom",
47+
"value": "x",
48+
"domain": "\n",
49+
}),
50+
},
51+
InvalidHeaderCase {
52+
name: "cookie path with DEL",
53+
properties: json!({
54+
"component": "cookie",
55+
"name": "boom",
56+
"value": "x",
57+
"path": "\u{007f}",
58+
}),
59+
},
60+
InvalidHeaderCase {
61+
name: "http_header value with carriage return",
62+
properties: json!({
63+
"component": "http_header",
64+
"X-Test": "\r",
65+
}),
66+
},
67+
InvalidHeaderCase {
68+
name: "redirect link with NUL",
69+
properties: json!({
70+
"component": "redirect",
71+
"link": "\u{0000}",
72+
}),
73+
},
74+
InvalidHeaderCase {
75+
name: "authentication link with unit separator",
76+
properties: json!({
77+
"component": "authentication",
78+
"link": "\u{001f}",
79+
}),
80+
},
81+
InvalidHeaderCase {
82+
name: "download filename with newline",
83+
properties: json!({
84+
"component": "download",
85+
"data_url": "data:text/plain,ok",
86+
"filename": "\n",
87+
}),
88+
},
89+
InvalidHeaderCase {
90+
name: "csv filename with carriage return",
91+
properties: json!({
92+
"component": "csv",
93+
"filename": "\r",
94+
}),
95+
},
96+
InvalidHeaderCase {
97+
name: "csv title with NUL",
98+
properties: json!({
99+
"component": "csv",
100+
"title": "\u{0000}",
101+
}),
102+
},
103+
];
104+
105+
for case in &cases {
106+
assert_invalid_header_response(case).await;
107+
}
108+
}

tests/errors/invalid_header.sql

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
select 'dynamic' as component, $properties as properties;

tests/errors/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use actix_web::{
55

66
use crate::common::req_path;
77
mod basic_auth;
8+
mod invalid_header;
89

910
#[actix_web::test]
1011
async fn test_privileged_paths_are_not_accessible() {

0 commit comments

Comments
 (0)