Skip to content

Commit afbe9b1

Browse files
authored
fix(oidc): apply protected/public path rules to the percent-decoded path (#1310)
An unauthenticated request could encode a byte of a protected prefix (e.g. /%70rotected/, which the router decodes to /protected/ and serves) to bypass oidc_protected_paths. is_public_path now percent-decodes both the request path and the configured prefixes before the plain string-prefix comparison, so encoded and plain URLs are classified identically, including when site_prefix contains percent-encoded characters such as a space. Plain string matching preserves the documented /public vs /public/ distinction.
1 parent 5548539 commit afbe9b1

2 files changed

Lines changed: 100 additions & 2 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 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 percent-decodes the request path and the configured prefixes before applying the rules, so encoded and plain URLs are classified identically (including when `site_prefix` contains characters such as a space). No configuration change is required; just upgrade.
78

89
## v0.44.0
910

src/webserver/oidc.rs

Lines changed: 99 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -140,8 +140,28 @@ impl TryFrom<&AppConfig> for OidcConfig {
140140
impl OidcConfig {
141141
#[must_use]
142142
pub fn is_public_path(&self, path: &str) -> bool {
143-
!self.protected_paths.iter().any(|p| path.starts_with(p))
144-
|| self.public_paths.iter().any(|p| path.starts_with(p))
143+
// Percent-decode both the request path and the configured prefixes
144+
// before comparing. Otherwise an unauthenticated request could encode a
145+
// byte of a protected prefix (e.g. `/%70rotected/`, which the router
146+
// decodes to `/protected/` and serves) to dodge the rule. The prefixes
147+
// are decoded too because a `site_prefix` such as `/my app/` is stored
148+
// percent-encoded (`/my%20app/...`); decoding only one side would never
149+
// match and would wrongly make protected pages public. Matching stays a
150+
// plain string prefix, preserving the documented `/public` vs `/public/`
151+
// distinction (those resolve to different files).
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
163+
.iter()
164+
.any(|p| path.starts_with(decode(p).as_ref()))
145165
}
146166

147167
/// Creates a custom ID token verifier that supports multiple issuers
@@ -1250,6 +1270,83 @@ mod tests {
12501270
verify_logout_params(&params, secret).expect("generated URL should validate");
12511271
}
12521272

1273+
fn test_oidc_config_with_paths(
1274+
protected_paths: Vec<String>,
1275+
public_paths: Vec<String>,
1276+
) -> OidcConfig {
1277+
OidcConfig {
1278+
issuer_url: IssuerUrl::new("https://example.com".to_string()).unwrap(),
1279+
client_id: "test_client".to_string(),
1280+
client_secret: "secret".to_string(),
1281+
protected_paths,
1282+
public_paths,
1283+
app_host: "example.com".to_string(),
1284+
scopes: vec![],
1285+
additional_audience_verifier: AudienceVerifier::new(None),
1286+
site_prefix: "/".to_string(),
1287+
redirect_uri: SQLPAGE_REDIRECT_URI.to_string(),
1288+
logout_uri: SQLPAGE_LOGOUT_URI.to_string(),
1289+
}
1290+
}
1291+
1292+
#[test]
1293+
fn percent_encoded_protected_path_is_not_public() {
1294+
let config = test_oidc_config_with_paths(vec!["/protected".to_string()], vec![]);
1295+
assert!(
1296+
!config.is_public_path("/protected/"),
1297+
"/protected/ must be protected"
1298+
);
1299+
// Encoding a byte of the prefix (%70 == 'p') must not bypass protection:
1300+
// the router percent-decodes the path and serves the protected file.
1301+
assert!(
1302+
!config.is_public_path("/%70rotected/"),
1303+
"/%70rotected/ decodes to /protected/ and must stay protected"
1304+
);
1305+
}
1306+
1307+
#[test]
1308+
fn percent_encoded_public_path_stays_public() {
1309+
let config = test_oidc_config_with_paths(
1310+
vec!["/protected".to_string()],
1311+
vec!["/protected/public".to_string()],
1312+
);
1313+
assert!(
1314+
config.is_public_path("/protected/%70ublic/page.sql"),
1315+
"/protected/%70ublic/... decodes to a public path"
1316+
);
1317+
}
1318+
1319+
#[test]
1320+
fn encoded_site_prefix_keeps_protected_paths_protected() {
1321+
// With a site_prefix like `/my app/`, the configured prefixes are stored
1322+
// encoded (`/my%20app/...`). Decoding only the request path would never
1323+
// match, so protected pages must not become public.
1324+
let config = test_oidc_config_with_paths(vec!["/my%20app/protected".to_string()], vec![]);
1325+
assert!(!config.is_public_path("/my%20app/protected/page.sql"));
1326+
assert!(!config.is_public_path("/my%20app/%70rotected/"));
1327+
assert!(
1328+
config.is_public_path("/my%20app/login.sql"),
1329+
"a non-protected page under the site prefix stays public"
1330+
);
1331+
}
1332+
1333+
#[test]
1334+
fn trailing_slash_public_prefix_does_not_expose_sibling_file() {
1335+
// A public exception for the `/public/` directory must not also make the
1336+
// extensionless `/public` (which the router resolves to public.sql)
1337+
// public. Plain string-prefix matching keeps them distinct.
1338+
let config =
1339+
test_oidc_config_with_paths(vec!["/".to_string()], vec!["/public/".to_string()]);
1340+
assert!(
1341+
config.is_public_path("/public/page.sql"),
1342+
"files under /public/ are public"
1343+
);
1344+
assert!(
1345+
!config.is_public_path("/public"),
1346+
"/public (the file public.sql) stays protected"
1347+
);
1348+
}
1349+
12531350
#[test]
12541351
fn evicts_excess_tmp_login_flow_state_cookies() {
12551352
let request = (0..MAX_OIDC_PARALLEL_LOGIN_FLOWS)

0 commit comments

Comments
 (0)