Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion crates/yerd-proxy/src/forward/fcgi.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -80,6 +83,7 @@ pub async fn forward(
backend: Backend,
served_root: PathBuf,
script_rel: Option<PathBuf>,
path_info: Option<String>,
server_addr: SocketAddr,
peer_addr: SocketAddr,
https: bool,
Expand Down Expand Up @@ -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,
Expand Down
98 changes: 95 additions & 3 deletions crates/yerd-proxy/src/forward/script_file.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}
}
Expand Down Expand Up @@ -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"<?php").unwrap();

let rel = resolve_script(
"/theme/styles.php/moove/123/all",
root.path(),
root.path(),
true,
)
.await;
assert_eq!(
rel,
ScriptResolution::ScriptWithPathInfo(
PathBuf::from("theme/styles.php"),
"/moove/123/all".to_owned()
)
);
}

#[tokio::test]
async fn resolves_slash_only_path_info_for_trailing_slash() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("file.php"), b"<?php").unwrap();

assert_eq!(
resolve_script("/file.php/", root.path(), root.path(), true).await,
ScriptResolution::ScriptWithPathInfo(PathBuf::from("file.php"), "/".to_owned())
);
}

#[tokio::test]
async fn resolves_path_info_containing_dot_dot_segments() {
let root = tempfile::tempdir().unwrap();
std::fs::write(root.path().join("file.php"), b"<?php").unwrap();

assert_eq!(
resolve_script("/file.php/../arg", root.path(), root.path(), true).await,
ScriptResolution::ScriptWithPathInfo(PathBuf::from("file.php"), "/../arg".to_owned())
);
assert_eq!(
resolve_script("/file.php/%2e%2e/arg", root.path(), root.path(), true).await,
ScriptResolution::ScriptWithPathInfo(PathBuf::from("file.php"), "/../arg".to_owned())
);
}

#[tokio::test]
async fn path_info_split_requires_the_script_to_exist() {
let root = tempfile::tempdir().unwrap();

assert_eq!(
resolve_script(
"/theme/styles.php/moove/123/all",
root.path(),
root.path(),
true
)
.await,
ScriptResolution::Fallback
);
}

#[tokio::test]
async fn resolves_subdirectory_index_for_trailing_slash() {
let root = tempfile::tempdir().unwrap();
Expand Down
29 changes: 25 additions & 4 deletions crates/yerd-proxy/src/pure/cgi_params.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,16 @@
//! - `SCRIPT_FILENAME = document_root / "index.php"`
//! - `SCRIPT_NAME = "/index.php"`
//!
//! `PATH_INFO` is always `<original path>` either way - WordPress and
//! `PATH_INFO` is `<original path>` 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.
//!
Expand Down Expand Up @@ -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=<path>` param plus a custom
/// `YERD_LOGIN_USER` param carrying the target username - see
/// [`AutoLoginParams`].
Expand All @@ -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,
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
Loading