Skip to content

Commit 70665a5

Browse files
committed
Classify OIDC paths with routing's exact decode
is_public_path reimplemented percent-decoding (lossy UTF-8, string match), diverging from routing::check_path on non-UTF-8 paths (Unix) and backslash separators (Windows), allowing protected files to be served as public. Extract routing's decode into a shared decode_percent_encoded_path and use it for OIDC classification, comparing on path-component boundaries so auth and routing always agree. Adds Unix non-UTF-8 and Windows backslash regression tests.
1 parent 24e8069 commit 70665a5

3 files changed

Lines changed: 68 additions & 37 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +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 fix: OIDC protected paths could be bypassed with percent-encoded URLs.** When `oidc_protected_paths` used a non-default prefix such as `["/protected"]`, an unauthenticated visitor could percent-encode a byte of the prefix (for example requesting `/%70rotected/`, where `%70` is `p`) to make the OIDC middleware treat the page as public and serve it without logging in, even though SQLPage then decoded the URL and ran the protected SQL file. You are affected if you rely on `oidc_protected_paths` (or `oidc_public_paths`) with prefixes other than the default `/`. The path is now percent-decoded before the protected/public rules are applied, so encoded and plain URLs are classified identically. No configuration change is required; just upgrade.
7+
- **Security fix: OIDC protected paths could be bypassed with percent-encoded URLs.** When `oidc_protected_paths` used a non-default prefix such as `["/protected"]`, an unauthenticated visitor could percent-encode a byte of the prefix (for example requesting `/%70rotected/`, where `%70` is `p`) to make the OIDC middleware treat the page as public and serve it without logging in, even though SQLPage then decoded the URL and ran the protected SQL file. You are affected if you rely on `oidc_protected_paths` (or `oidc_public_paths`) with prefixes other than the default `/`. SQLPage now classifies the request with the exact same path decoding the router uses to pick the file, so percent-encoded URLs, non-UTF-8 byte paths on Unix, and `\` (a path separator on Windows) are all treated identically by authentication and by routing. Matching is now on path-component boundaries, so a protected `/admin` covers `/admin` and `/admin/...` but not an unrelated `/administrator`. No configuration change is required; just upgrade.
88

99
## v0.44.0
1010

src/webserver/oidc.rs

Lines changed: 47 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ use tracing::Instrument;
4040

4141
use super::error::anyhow_err_to_actix_resp;
4242
use super::http_client::make_http_client;
43+
use super::routing::decode_percent_encoded_path;
4344

4445
type LocalBoxFuture<T> = Pin<Box<dyn Future<Output = T> + 'static>>;
4546

@@ -140,28 +141,22 @@ impl TryFrom<&AppConfig> for OidcConfig {
140141
impl OidcConfig {
141142
#[must_use]
142143
pub fn is_public_path(&self, path: &str) -> bool {
143-
// Percent-decode before matching, so an encoded protected prefix
144-
// (e.g. "/%70rotected" == "/protected") is classified the same way the
145-
// router resolves it once decoded. Both the request path AND the
146-
// configured prefixes are decoded: `site_prefix` may itself contain
147-
// percent-encoded characters (e.g. a space -> "%20"), so the stored
148-
// prefixes can be encoded too. Comparing a decoded path against an
149-
// encoded prefix would never match and would wrongly make every
150-
// protected page public. Decode both sides so the comparison matches
151-
// what routing actually serves.
152-
fn decode(s: &str) -> std::borrow::Cow<'_, str> {
153-
percent_encoding::percent_decode_str(s).decode_utf8_lossy()
154-
}
155-
let decoded = decode(path);
156-
let path = decoded.as_ref();
157-
!self
158-
.protected_paths
159-
.iter()
160-
.any(|p| path.starts_with(decode(p).as_ref()))
161-
|| self
162-
.public_paths
144+
// Classify the request using the exact same decoding the router uses to
145+
// resolve the file (`routing::decode_percent_encoded_path`), so
146+
// authentication and file resolution can never disagree. This handles
147+
// percent-encoded bytes, non-UTF-8 paths on Unix, and `\` as a separator
148+
// on Windows identically to routing. The configured prefixes are decoded
149+
// the same way (a `site_prefix` such as `/my app/` is stored encoded as
150+
// `/my%20app/`), and matching is done on path-component boundaries, so a
151+
// protected `/admin` covers `/admin/...` but not an unrelated
152+
// `/administrator`, mirroring how routing maps URLs to files.
153+
let decoded = decode_percent_encoded_path(path);
154+
let matches_any = |prefixes: &[String]| {
155+
prefixes
163156
.iter()
164-
.any(|p| path.starts_with(decode(p).as_ref()))
157+
.any(|p| decoded.starts_with(decode_percent_encoded_path(p)))
158+
};
159+
!matches_any(&self.protected_paths) || matches_any(&self.public_paths)
165160
}
166161

167162
/// Creates a custom ID token verifier that supports multiple issuers
@@ -1358,6 +1353,37 @@ mod tests {
13581353
);
13591354
}
13601355

1356+
#[cfg(unix)]
1357+
#[test]
1358+
fn non_utf8_path_is_classified_like_routing() {
1359+
// Protected root, plus a public prefix containing a non-UTF-8 byte.
1360+
// A request for a DIFFERENT non-UTF-8 byte must not be misclassified as
1361+
// public: lossy UTF-8 decoding would collapse both %FF and %FE to the
1362+
// same replacement char, but routing serves distinct byte paths.
1363+
let config =
1364+
test_oidc_config_with_paths(vec!["/".to_string()], vec!["/public/%FF".to_string()]);
1365+
assert!(
1366+
!config.is_public_path("/public/%FE/secret.sql"),
1367+
"/public/%FE/... is a different file than the public /public/%FF and must stay protected"
1368+
);
1369+
assert!(
1370+
config.is_public_path("/public/%FF/page.sql"),
1371+
"the configured public path itself must stay public"
1372+
);
1373+
}
1374+
1375+
// Windows treats `\` as a path separator, like routing's PathBuf. This runs
1376+
// only on Windows CI; on Unix `\` is a literal byte and the case differs.
1377+
#[cfg(windows)]
1378+
#[test]
1379+
fn windows_backslash_path_is_classified_like_routing() {
1380+
let config = test_oidc_config_with_paths(vec!["/admin/".to_string()], vec![]);
1381+
assert!(
1382+
!config.is_public_path("/admin%5Csecret.sql"),
1383+
"/admin\\secret.sql resolves under /admin/ on Windows and must stay protected"
1384+
);
1385+
}
1386+
13611387
#[test]
13621388
fn evicts_excess_tmp_login_flow_state_cookies() {
13631389
let request = (0..MAX_OIDC_PARALLEL_LOGIN_FLOWS)

src/webserver/routing.rs

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -172,21 +172,26 @@ where
172172
{
173173
match path_and_query.path().strip_prefix(config.prefix()) {
174174
None => Err(Redirect(config.prefix().to_string())),
175-
Some(path) => {
176-
let decoded = percent_encoding::percent_decode_str(path);
177-
#[cfg(unix)]
178-
{
179-
use std::ffi::OsString;
180-
use std::os::unix::ffi::OsStringExt;
181-
182-
let decoded = decoded.collect::<Vec<u8>>();
183-
Ok(PathBuf::from(OsString::from_vec(decoded)))
184-
}
185-
#[cfg(not(unix))]
186-
{
187-
Ok(PathBuf::from(decoded.decode_utf8_lossy().as_ref()))
188-
}
189-
}
175+
Some(path) => Ok(decode_percent_encoded_path(path)),
176+
}
177+
}
178+
179+
/// Percent-decodes a request path into a `PathBuf`, preserving the exact decoded
180+
/// bytes on Unix and applying platform path semantics (for example `\` is a
181+
/// separator on Windows). OIDC path classification reuses this so authentication
182+
/// and file resolution always agree on which file a request targets.
183+
pub(crate) fn decode_percent_encoded_path(path: &str) -> PathBuf {
184+
let decoded = percent_encoding::percent_decode_str(path);
185+
#[cfg(unix)]
186+
{
187+
use std::ffi::OsString;
188+
use std::os::unix::ffi::OsStringExt;
189+
190+
PathBuf::from(OsString::from_vec(decoded.collect::<Vec<u8>>()))
191+
}
192+
#[cfg(not(unix))]
193+
{
194+
PathBuf::from(decoded.decode_utf8_lossy().as_ref())
190195
}
191196
}
192197

0 commit comments

Comments
 (0)