Skip to content

Commit 8e359d2

Browse files
authored
Merge branch 'main' into ophir.lojkine/fix-oidc-logout-session-binding
2 parents 6e22fdc + fe13e4a commit 8e359d2

14 files changed

Lines changed: 199 additions & 41 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
## Unreleased
44

55
- **Security: OIDC logout links are now bound to the session that requested them.** Previously, a logout URL produced by `sqlpage.oidc_logout_url` only signed the redirect target and a timestamp, so any visitor who opened a still-valid logout link had their SQLPage auth cookies cleared. This allowed a forced logout via CSRF (a logout link made for one user could log out another, or be replayed). It did NOT allow account takeover or access to anyone's data. Logout links are now tied to the current `sqlpage_auth` cookie, so following one only logs out the browser it was generated for. You are affected only if you use built-in OIDC authentication together with `sqlpage.oidc_logout_url`. No action is needed: the normal "click your own logout link" flow keeps working, and old links simply stop being honored for other sessions.
6+
- **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.
7+
- **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.
68

79
## v0.44.0
810

SECURITY.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,24 @@ SQLPage vulnerabilities:
129129
- An operator intentionally changes configuration to expose files, trust a
130130
different database, make an OIDC path public, weaken CSP, enable dangerous
131131
Markdown options, load SQLite extensions, or enable `allow_exec`.
132+
- A symlink placed under `web_root` exposes its target. SQLPage follows
133+
symlinks during static file serving, so operators must not create symlinks
134+
under `web_root` that point to reserved or private files, such as the
135+
`sqlpage/` configuration directory or dotfiles, or to files outside
136+
`web_root`, since those targets would then be publicly reachable over HTTP.
132137
- An attacker can modify SQL files, templates, configuration, environment
133138
variables, migrations, database code, or `sqlpage_files`.
134139
- The configured database role has broader permissions than the application
135140
needs.
136141
- A SQLPage application is publicly reachable because no authentication was
137142
configured.
143+
- An attacker can plant or overwrite cookies for the SQLPage origin (for
144+
example through a compromised subdomain, a sibling application on a shared
145+
parent domain, or a man-in-the-middle on plain HTTP). Attacks that depend on
146+
injecting attacker-chosen cookies into the victim's browser, such as OIDC
147+
login CSRF or session fixation via a forged login-flow-state cookie, are out
148+
of scope. SQLPage assumes its origin's cookie jar is writable only by the
149+
user agent, not by attackers.
138150
- Trusted SQL asks SQLPage or the database to perform expensive work.
139151

140152
These may still be serious and should be fixed in the affected application,

configuration.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ Here are the available configuration options and their default values:
2020
| `database_connection_retries` | 6 | Database connection attempts before giving up. Retries will happen every 5 seconds. |
2121
| `database_connection_acquire_timeout_seconds` | 10 | How long to wait when acquiring a database connection from the pool before giving up and returning an error. |
2222
| `sqlite_extensions` | | An array of SQLite extensions to load, such as `mod_spatialite` |
23-
| `web_root` | `.` | The root directory of the web server, where the `index.sql` file is located. |
23+
| `web_root` | `.` | The root directory of the web server, where the `index.sql` file is located. Static file serving follows symlinks, so do not place symlinks under `web_root` that point to private paths (such as the `sqlpage/` config directory) or to files outside `web_root`, as their targets would become publicly reachable (see [`SECURITY.md`](./SECURITY.md)). |
2424
| `site_prefix` | `/` | Base path of the site. If you want to host SQLPage at `https://example.com/sqlpage/`, set this to `/sqlpage/`. When using a reverse proxy, this allows hosting SQLPage together with other applications on the same subdomain. |
2525
| `configuration_directory` | `./sqlpage/` | The directory where the `sqlpage.json` file is located. This is used to find the path to [`templates/`](https://sql-page.com/custom_components.sql), [`migrations/`](https://sql-page.com/your-first-sql-website/migrations.sql), and `on_connect.sql`. Obviously, this configuration parameter can be set only through environment variables, not through the `sqlpage.json` file itself in order to find the `sqlpage.json` file. Be careful not to use a path that is accessible from the public WEB_ROOT |
2626
| `allow_exec` | false | Allow usage of the `sqlpage.exec` function. Do this only if all users with write access to sqlpage query files and to the optional `sqlpage_files` table on the database are trusted. |

examples/official-site/sqlpage/migrations/39_persist_uploaded_file.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,9 @@ VALUES (
5252
'persist_uploaded_file',
5353
2,
5454
'destination_folder',
55-
'Optional. Path to the folder where the file will be saved, relative to the web root (the root folder of your website files). By default, the file will be saved in the `uploads` folder.',
55+
'Optional. Path to the folder where the file will be saved, relative to the web root (the root folder of your website files). By default, the file will be saved in the `uploads` folder.
56+
57+
**Security note**: this value must be a folder name you choose yourself in your SQL code (a trusted constant). Never build it from untrusted input such as a form field, a query parameter, a request header, or anything else the visitor controls. The folder is joined directly to the web root, so a value containing `..` or an absolute path would cause the uploaded file to be written *outside* the web root, anywhere the SQLPage process can write. Keeping `destination_folder` a fixed value chosen by the application author avoids this.',
5658
'TEXT'
5759
),
5860
(
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/render.rs

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,9 @@ use crate::webserver::response_writer::{AsyncResponseWriter, ResponseWriter};
4949
use actix_web::body::MessageBody;
5050
use actix_web::cookie::time::OffsetDateTime;
5151
use actix_web::cookie::time::format_description::well_known::Rfc3339;
52-
use actix_web::http::header::TryIntoHeaderPair;
52+
use actix_web::http::header::{
53+
ContentDisposition, DispositionParam, DispositionType, TryIntoHeaderPair,
54+
};
5355
use actix_web::http::{StatusCode, header};
5456
use actix_web::{HttpResponse, HttpResponseBuilder};
5557
use anyhow::{Context as AnyhowContext, bail, format_err};
@@ -301,10 +303,7 @@ impl HeaderContext {
301303
get_object_str(options, "filename").or_else(|| get_object_str(options, "title"))
302304
{
303305
let extension = if filename.contains('.') { "" } else { ".csv" };
304-
self.insert_header((
305-
header::CONTENT_DISPOSITION,
306-
format!("attachment; filename={filename}{extension}"),
307-
))?;
306+
self.insert_header(attachment_with_filename(&format!("{filename}{extension}")))?;
308307
}
309308
let csv_renderer = CsvBodyRenderer::new(self.writer, options).await?;
310309
let renderer = AnyRenderBodyContext::Csv(csv_renderer);
@@ -345,10 +344,7 @@ impl HeaderContext {
345344

346345
fn download(mut self, options: &JsonValue) -> anyhow::Result<PageContext> {
347346
if let Some(filename) = get_object_str(options, "filename") {
348-
self.insert_header((
349-
header::CONTENT_DISPOSITION,
350-
format!("attachment; filename=\"{filename}\""),
351-
))?;
347+
self.insert_header(attachment_with_filename(filename))?;
352348
}
353349
let data_url = get_object_str(options, "data_url")
354350
.with_context(|| "The download component requires a 'data_url' property")?;
@@ -442,6 +438,18 @@ async fn verify_password_async(
442438
.await?
443439
}
444440

441+
/// Builds an `attachment` `Content-Disposition` header with the given filename,
442+
/// using actix-web's structured [`ContentDisposition`] type so the filename is
443+
/// properly quoted and escaped. This prevents a user-supplied filename
444+
/// containing `;`, `"`, or `=` from injecting additional header parameters
445+
/// (e.g. a second, agent-preferred `filename*`).
446+
fn attachment_with_filename(filename: &str) -> ContentDisposition {
447+
ContentDisposition {
448+
disposition: DispositionType::Attachment,
449+
parameters: vec![DispositionParam::Filename(filename.to_owned())],
450+
}
451+
}
452+
445453
fn get_object_str<'a>(json: &'a JsonValue, key: &str) -> Option<&'a str> {
446454
json.as_object()
447455
.and_then(|obj| obj.get(key))

src/webserver/database/sqlpage_functions/functions.rs

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -512,7 +512,12 @@ async fn persist_uploaded_file<'a>(
512512
let exts = allowed_extensions.collect::<Vec<_>>().join(", ");
513513
anyhow::bail!("file extension {extension} is not allowed. Allowed extensions: {exts}");
514514
}
515-
// resolve the folder path relative to the web root
515+
// Resolve the folder path relative to the web root.
516+
// `folder` is trusted application input: it is expected to be a constant chosen by the
517+
// app author in their SQL code, never attacker-controlled request data. It is joined
518+
// directly to the web root, so a `folder` containing `..` or an absolute path would let
519+
// the caller write the uploaded file outside the web root. Callers must not pass
520+
// untrusted input (form fields, query parameters, headers, ...) as the folder.
516521
let web_root = &request.app_state.config.web_root;
517522
let target_folder = web_root.join(&*folder);
518523
// create the folder if it doesn't exist

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 {

0 commit comments

Comments
 (0)