diff --git a/crates/yerd-proxy/src/forward/fcgi.rs b/crates/yerd-proxy/src/forward/fcgi.rs index 606c73d3..f2666cdc 100644 --- a/crates/yerd-proxy/src/forward/fcgi.rs +++ b/crates/yerd-proxy/src/forward/fcgi.rs @@ -71,7 +71,10 @@ const MAX_DISCARDED_BYTES: u64 = 8 * 1024 * 1024; /// `script_rel`, if given, is a real, on-disk `.php` file (relative to /// `served_root`) that [`crate::forward::script_file::resolve_script`] /// resolved for this request - see `pure::cgi_params`'s module doc for the -/// front-controller policy this drives. `auto_login`, if given, is passed +/// front-controller policy this drives. `path_info`, if given, is the +/// decoded remainder of a `PATH_INFO`-style split +/// ([`crate::forward::script_file::ScriptResolution::ScriptWithPathInfo`]) +/// and overrides the default full-path `PATH_INFO`. `auto_login`, if given, is passed /// straight through to [`build_params`] - see [`AutoLoginParams`] for /// when/why. #[allow(clippy::too_many_arguments)] @@ -80,6 +83,7 @@ pub async fn forward( backend: Backend, served_root: PathBuf, script_rel: Option, + path_info: Option, server_addr: SocketAddr, peer_addr: SocketAddr, https: bool, @@ -109,6 +113,7 @@ pub async fn forward( &parts.headers, &served_root, script_rel.as_deref(), + path_info.as_deref(), https, peer_addr, server_addr, diff --git a/crates/yerd-proxy/src/forward/script_file.rs b/crates/yerd-proxy/src/forward/script_file.rs index d9a4f4c4..21b31a6f 100644 --- a/crates/yerd-proxy/src/forward/script_file.rs +++ b/crates/yerd-proxy/src/forward/script_file.rs @@ -24,13 +24,19 @@ use std::path::{Path, PathBuf}; use crate::forward::static_file::{canonical_within, Containment}; -use crate::pure::try_files::{directory_candidate, is_php_source, static_candidate}; +use crate::pure::try_files::{ + directory_candidate, is_php_source, php_split_candidate, static_candidate, +}; /// Outcome of resolving a direct-mode request against the on-disk tree. #[derive(Debug, PartialEq, Eq)] pub enum ScriptResolution { /// A real, on-disk PHP script to execute, relative to `served_root`. Script(PathBuf), + /// A real, on-disk PHP script addressed `PATH_INFO`-style + /// (`/styles.php/extra/args`): the script to execute plus the decoded + /// `PATH_INFO` remainder to hand FastCGI. + ScriptWithPathInfo(PathBuf, String), /// The path names a real directory without a trailing slash; answer /// `301` to the trailing-slash form. DirectoryRedirect, @@ -72,15 +78,37 @@ pub async fn resolve_script( if is_existing_directory(served_root, &real_root, &rel, symlink_protection).await { return ScriptResolution::DirectoryRedirect; } - return ScriptResolution::Fallback; + return split_path_info(uri_path, served_root, &real_root, symlink_protection).await; } let Some(dir_rel) = directory_candidate(uri_path) else { - return ScriptResolution::Fallback; + return split_path_info(uri_path, served_root, &real_root, symlink_protection).await; }; let script_rel = dir_rel.join("index.php"); match existing_php_file(served_root, &real_root, &script_rel, symlink_protection).await { Some(script) => ScriptResolution::Script(script), + None => split_path_info(uri_path, served_root, &real_root, symlink_protection).await, + } +} + +/// The `fastcgi_split_path_info` half of [`resolve_script`]: when the path +/// embeds extra segments after a PHP script (`/theme/styles.php/moove/1/all`, +/// Moodle's "slash arguments" and classic CGI/1.1 `PATH_INFO` addressing), +/// execute that script - if it really exists on disk under the same +/// containment discipline as an exact match - with the remainder as +/// `PATH_INFO`. Tried only after the exact-file and directory answers have +/// been ruled out, so it can never shadow a real file or directory. +async fn split_path_info( + uri_path: &str, + served_root: &Path, + real_root: &Path, + symlink_protection: bool, +) -> ScriptResolution { + let Some((script_rel, path_info)) = php_split_candidate(uri_path) else { + return ScriptResolution::Fallback; + }; + match existing_php_file(served_root, real_root, &script_rel, symlink_protection).await { + Some(script) => ScriptResolution::ScriptWithPathInfo(script, path_info), None => ScriptResolution::Fallback, } } @@ -175,6 +203,70 @@ mod tests { assert_eq!(rel, ScriptResolution::Script(PathBuf::from("wp-login.php"))); } + #[tokio::test] + async fn resolves_path_info_split_for_real_script() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("theme")).unwrap(); + std::fs::write(root.path().join("theme/styles.php"), b"` either way - WordPress and +//! `PATH_INFO` is `` in both cases - WordPress and //! Laravel both route on `REQUEST_URI`, not `PATH_INFO`, so leaving it as the //! full original path (rather than splitting "extra path after the script", //! full CGI/1.1 `PATH_INFO` semantics) keeps this a minimal, low-risk change -//! on top of already-pinned behavior. +//! on top of already-pinned behavior. The exception is a `PATH_INFO`-style +//! request the resolver *split* (`/styles.php/extra` - see +//! `forward::script_file::ScriptResolution::ScriptWithPathInfo`): there the +//! caller passes the split remainder as `path_info` and `PATH_INFO` carries +//! exactly that, which is what nginx's `fastcgi_split_path_info` produces and +//! what Moodle-style slash-argument routing reads. //! //! Plus the standard CGI/1.1 vars and `HTTP_*`-translated headers. //! @@ -57,7 +62,10 @@ pub struct AutoLoginParams<'a> { /// Build the CGI parameter pairs. `script_rel`, if given, is a real, /// on-disk `.php` file's path relative to `document_root` (see the module -/// doc) - `None` falls back to the root `index.php` policy. `auto_login`, if +/// doc) - `None` falls back to the root `index.php` policy. `path_info`, if +/// given, is the decoded remainder of a `PATH_INFO`-style split resolved by +/// `forward::script_file` and overrides the default `PATH_INFO` value (the +/// full request path). `auto_login`, if /// given, adds a `PHP_VALUE: auto_prepend_file=` param plus a custom /// `YERD_LOGIN_USER` param carrying the target username - see /// [`AutoLoginParams`]. @@ -69,6 +77,7 @@ pub fn build_params( headers: &http::HeaderMap, document_root: &Path, script_rel: Option<&Path>, + path_info: Option<&str>, https: bool, remote_addr: SocketAddr, server_addr: SocketAddr, @@ -101,7 +110,7 @@ pub fn build_params( b"DOCUMENT_ROOT", document_root.to_string_lossy().as_bytes(), ); - push(&mut out, b"PATH_INFO", path.as_bytes()); + push(&mut out, b"PATH_INFO", path_info.unwrap_or(path).as_bytes()); push( &mut out, b"REMOTE_ADDR", @@ -221,6 +230,7 @@ mod tests { &make_headers("app.test"), &root, None, + None, false, "127.0.0.1:54321".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -261,6 +271,7 @@ mod tests { &make_headers("app.test"), Path::new("/srv"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -283,6 +294,7 @@ mod tests { &make_headers("app.test"), &served, None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -306,6 +318,7 @@ mod tests { &make_headers("app.test"), Path::new("/srv/www/app"), None, + None, true, "1.2.3.4:1000".parse().unwrap(), "127.0.0.1:443".parse().unwrap(), @@ -325,6 +338,7 @@ mod tests { &headers, Path::new("/srv"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -351,6 +365,7 @@ mod tests { &headers, Path::new("/srv"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -373,6 +388,7 @@ mod tests { &make_headers("a.test"), Path::new("/srv"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -390,6 +406,7 @@ mod tests { &make_headers("blog.test"), Path::new("/srv/www/blog"), Some(Path::new("wp-admin/index.php")), + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -416,6 +433,7 @@ mod tests { &make_headers("blog.test"), Path::new("/srv/www/blog"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -442,6 +460,7 @@ mod tests { &make_headers("blog.test"), Path::new("/srv/www/blog"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -461,6 +480,7 @@ mod tests { &make_headers("app.test"), Path::new("/srv/www/app"), None, + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), @@ -478,6 +498,7 @@ mod tests { &make_headers("blog.test"), Path::new("/srv/www/blog"), Some(Path::new("wp-login.php")), + None, false, "127.0.0.1:1".parse().unwrap(), "127.0.0.1:80".parse().unwrap(), diff --git a/crates/yerd-proxy/src/pure/try_files.rs b/crates/yerd-proxy/src/pure/try_files.rs index 713c7878..ab0dc002 100644 --- a/crates/yerd-proxy/src/pure/try_files.rs +++ b/crates/yerd-proxy/src/pure/try_files.rs @@ -75,6 +75,66 @@ fn resolve_segments(path: &str) -> Option { Some(rel) } +/// Split a `PATH_INFO`-style URL path at its first PHP-source segment - the +/// non-greedy half of nginx's `fastcgi_split_path_info ^(.+?\.php)(/.*)$` - +/// into the candidate script path and the decoded `PATH_INFO` remainder +/// (always starting with `/`). +/// +/// `None` when no segment with trailing path data is PHP source (a plain +/// `/foo.php` has no remainder and is not a split; `/foo.php/` has the +/// slash-only remainder `/`), or when the script half fails the +/// same percent-decoding/traversal guard as [`static_candidate`]. Remainder +/// segments are decoded with the same escapes rule but deliberately allow +/// `.`/`..` - `PATH_INFO` is opaque data for the script, not a filesystem +/// path - while still refusing embedded `/`, `\`, and NUL after decoding. A +/// trailing `/` on the request survives into the remainder, matching what +/// nginx's regex captures. The caller must still verify the script half is a +/// real, on-disk file before trusting the split. +#[must_use] +pub fn php_split_candidate(url_path: &str) -> Option<(PathBuf, String)> { + let path = url_path.split('?').next().unwrap_or(url_path); + let trailing_slash = path.len() > 1 && path.ends_with('/'); + + let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); + let searchable = if trailing_slash { + segments.len() + } else { + segments.len().saturating_sub(1) + }; + + let split_at = segments + .iter() + .take(searchable) + .position(|raw| percent_decode(raw).is_some_and(|seg| is_php_source(Path::new(&seg))))?; + + let mut script = PathBuf::new(); + for raw in segments.get(..=split_at)? { + let seg = percent_decode(raw)?; + if seg.is_empty() || seg == "." || seg == ".." { + return None; + } + if seg.bytes().any(|b| b == b'/' || b == b'\\' || b == 0) { + return None; + } + script.push(seg); + } + + let mut info = String::new(); + for raw in segments.get(split_at + 1..)? { + let seg = percent_decode(raw)?; + if seg.bytes().any(|b| b == b'/' || b == b'\\' || b == 0) { + return None; + } + info.push('/'); + info.push_str(&seg); + } + if trailing_slash { + info.push('/'); + } + + Some((script, info)) +} + /// Whether `path` looks like PHP source - these must never be served as a static /// file (it would leak source), so the front controller handles them instead. #[must_use] @@ -237,6 +297,63 @@ mod tests { assert_eq!(directory_candidate("/foo%2fbar/"), None); } + #[test] + fn php_split_finds_first_php_segment() { + assert_eq!( + php_split_candidate("/theme/styles.php/moove/123/all"), + Some(( + PathBuf::from("theme/styles.php"), + "/moove/123/all".to_owned() + )) + ); + assert_eq!( + php_split_candidate("/lib/javascript.php/1/lib/javascript-static.js"), + Some(( + PathBuf::from("lib/javascript.php"), + "/1/lib/javascript-static.js".to_owned() + )) + ); + assert_eq!( + php_split_candidate("/a.php/b.php/c"), + Some((PathBuf::from("a.php"), "/b.php/c".to_owned())) + ); + } + + #[test] + fn php_split_yields_slash_only_path_info_for_trailing_slash() { + assert_eq!( + php_split_candidate("/file.php/"), + Some((PathBuf::from("file.php"), "/".to_owned())) + ); + } + + #[test] + fn php_split_keeps_trailing_slash_and_decodes() { + assert_eq!( + php_split_candidate("/file.php/dir/"), + Some((PathBuf::from("file.php"), "/dir/".to_owned())) + ); + assert_eq!( + php_split_candidate("/file.php/my%20arg?x=1"), + Some((PathBuf::from("file.php"), "/my arg".to_owned())) + ); + } + + #[test] + fn php_split_ignores_plain_and_non_php_paths() { + assert_eq!(php_split_candidate("/wp-login.php"), None); + assert_eq!(php_split_candidate("/assets/app.css"), None); + assert_eq!(php_split_candidate("/foo/bar"), None); + assert_eq!(php_split_candidate("/"), None); + } + + #[test] + fn php_split_rejects_traversal_in_script_half() { + assert_eq!(php_split_candidate("/../evil.php/x"), None); + assert_eq!(php_split_candidate("/%2e%2e/evil.php/x"), None); + assert_eq!(php_split_candidate("/a%2fb.php/x"), None); + } + #[test] fn php_sources_are_flagged() { assert!(is_php_source(Path::new("index.php"))); diff --git a/crates/yerd-proxy/src/server.rs b/crates/yerd-proxy/src/server.rs index 35248585..36ea5122 100644 --- a/crates/yerd-proxy/src/server.rs +++ b/crates/yerd-proxy/src/server.rs @@ -536,8 +536,11 @@ async fn serve_php_fpm( symlink_protection, ) .await; - let script_rel = match resolution { - script_file::ScriptResolution::Script(rel) => Some(rel), + let (script_rel, path_info) = match resolution { + script_file::ScriptResolution::Script(script) => (Some(script), None), + script_file::ScriptResolution::ScriptWithPathInfo(script, extra) => { + (Some(script), Some(extra)) + } script_file::ScriptResolution::DirectoryRedirect if *req.method() == Method::GET || *req.method() == Method::HEAD => { @@ -547,8 +550,8 @@ async fn serve_php_fpm( | script_file::ScriptResolution::Fallback => { match apply_route(&req, route, served_root, allowed_root, symlink_protection).await { RouteOutcome::Respond(resp) => return Ok(resp), - RouteOutcome::Script(rel) => Some(rel), - RouteOutcome::Fallback => None, + RouteOutcome::Script(rel) => (Some(rel), None), + RouteOutcome::Fallback => (None, None), } } }; @@ -565,6 +568,7 @@ async fn serve_php_fpm( backend, served_root.to_path_buf(), script_rel, + path_info, server_addr, peer_addr, https,