-
Notifications
You must be signed in to change notification settings - Fork 227
In ServeDir, change the behaviour for handling symlinks #658
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,6 +40,10 @@ const DEFAULT_CAPACITY: usize = 65536; | |
| /// existing file (`/file.html/something`) | ||
| /// - We don't have necessary permissions to read the file | ||
| /// | ||
| /// On linux, if a file is a symlink to a file outside the base directory, | ||
| /// a 500 error will be returned, unless `follow_symlinks_outside_base` | ||
| /// is set to true. | ||
| /// | ||
| /// # Example | ||
| /// | ||
| /// ``` | ||
|
|
@@ -59,6 +63,7 @@ pub struct ServeDir<F = DefaultServeDirFallback> { | |
| variant: ServeVariant, | ||
| fallback: Option<F>, | ||
| call_fallback_on_method_not_allowed: bool, | ||
| follow_symlinks_outside_base: bool, | ||
| } | ||
|
|
||
| impl ServeDir<DefaultServeDirFallback> { | ||
|
|
@@ -79,6 +84,7 @@ impl ServeDir<DefaultServeDirFallback> { | |
| }, | ||
| fallback: None, | ||
| call_fallback_on_method_not_allowed: false, | ||
| follow_symlinks_outside_base: false, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -93,6 +99,7 @@ impl ServeDir<DefaultServeDirFallback> { | |
| variant: ServeVariant::SingleFile { mime }, | ||
| fallback: None, | ||
| call_fallback_on_method_not_allowed: false, | ||
| follow_symlinks_outside_base: false, | ||
| } | ||
| } | ||
| } | ||
|
|
@@ -217,6 +224,7 @@ impl<F> ServeDir<F> { | |
| variant: self.variant, | ||
| fallback: Some(new_fallback), | ||
| call_fallback_on_method_not_allowed: self.call_fallback_on_method_not_allowed, | ||
| follow_symlinks_outside_base: self.follow_symlinks_outside_base, | ||
| } | ||
| } | ||
|
|
||
|
|
@@ -249,6 +257,16 @@ impl<F> ServeDir<F> { | |
| self | ||
| } | ||
|
|
||
| /// Start following symlinks outside the base directory. | ||
| /// | ||
| /// Warning: if you have a writeable directory inside the base directory, this | ||
| /// means that a local user can exfiltrate any file that the server process have | ||
| /// read access to. | ||
| pub fn follow_symlinks_outside_base(mut self, follow_symlinks_outside_base: bool) -> Self { | ||
| self.follow_symlinks_outside_base = follow_symlinks_outside_base; | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a reason not to gate this method behind conditional configuration targeting linux? I'm fine to punt on support for other platforms, but I'd rather not have this be a silent non-op. |
||
| self | ||
| } | ||
|
|
||
| /// Call the service and get a future that contains any `std::io::Error` that might have | ||
| /// happened. | ||
| /// | ||
|
|
@@ -381,6 +399,8 @@ impl<F> ServeDir<F> { | |
| negotiated_encodings, | ||
| range_header, | ||
| buf_chunk_size, | ||
| self.base.clone(), | ||
| self.follow_symlinks_outside_base, | ||
| )); | ||
|
|
||
| ResponseFuture::open_file_future(open_file_future, fallback_and_request) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -40,13 +40,16 @@ pub(super) enum FileRequestExtent { | |
| Head(Metadata), | ||
| } | ||
|
|
||
| #[allow(clippy::too_many_arguments)] | ||
| pub(super) async fn open_file( | ||
| variant: ServeVariant, | ||
| mut path_to_file: PathBuf, | ||
| req: Request<Empty<Bytes>>, | ||
| negotiated_encodings: Vec<(Encoding, QValue)>, | ||
| range_header: Option<String>, | ||
| buf_chunk_size: usize, | ||
| base_path: PathBuf, | ||
| follow_symlinks_outside_base: bool, | ||
| ) -> io::Result<OpenFileOutput> { | ||
| let if_unmodified_since = req | ||
| .headers() | ||
|
|
@@ -110,15 +113,21 @@ pub(super) async fn open_file( | |
| last_modified, | ||
| }))) | ||
| } else { | ||
| let (mut file, maybe_encoding) = | ||
| match open_file_with_fallback(path_to_file, negotiated_encodings).await { | ||
| Ok(result) => result, | ||
| let (mut file, maybe_encoding) = match open_file_with_fallback( | ||
| path_to_file, | ||
| negotiated_encodings, | ||
| &base_path, | ||
| follow_symlinks_outside_base, | ||
| ) | ||
| .await | ||
| { | ||
| Ok(result) => result, | ||
|
|
||
| Err(err) if is_invalid_filename_error(&err) => { | ||
| return Ok(OpenFileOutput::InvalidFilename) | ||
| } | ||
| Err(err) => return Err(err), | ||
| }; | ||
| Err(err) if is_invalid_filename_error(&err) => { | ||
| return Ok(OpenFileOutput::InvalidFilename) | ||
| } | ||
| Err(err) => return Err(err), | ||
| }; | ||
|
|
||
| let meta = file.metadata().await?; | ||
| let last_modified = meta.modified().ok().map(LastModified::from); | ||
|
|
@@ -226,17 +235,74 @@ fn preferred_encoding( | |
| preferred_encoding | ||
| } | ||
|
|
||
| fn canonicalize_and_openat2(base_path: &Path, path: &Path) -> io::Result<File> { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Currently
Can we shift to using the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will return an |
||
| let (path, base_path2) = if base_path.is_file() || !base_path.exists() { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We already know whether we are |
||
| let base_path = base_path.parent().unwrap().canonicalize()?; | ||
| let path = path | ||
| .canonicalize()? | ||
| .strip_prefix(&base_path) | ||
| .map_err(|err| io::Error::new(io::ErrorKind::Other, err))? | ||
| .to_owned(); | ||
| ( | ||
| path, | ||
| rustix::fs::open( | ||
| base_path, | ||
| rustix::fs::OFlags::RDONLY, | ||
| rustix::fs::Mode::empty(), | ||
| )?, | ||
| ) | ||
| } else { | ||
| let base_path = base_path.canonicalize()?; | ||
| let path = path | ||
| .canonicalize()? | ||
| .strip_prefix(&base_path) | ||
| .map_err(|err| io::Error::new(io::ErrorKind::Other, err))? | ||
| .to_owned(); | ||
| ( | ||
| path, | ||
| rustix::fs::open( | ||
| base_path, | ||
| rustix::fs::OFlags::RDONLY, | ||
| rustix::fs::Mode::empty(), | ||
| )?, | ||
| ) | ||
| }; | ||
|
|
||
| rustix::fs::openat2( | ||
| base_path2, | ||
| &path, | ||
| rustix::fs::OFlags::RDONLY, | ||
| rustix::fs::Mode::empty(), | ||
| rustix::fs::ResolveFlags::BENEATH, | ||
| ) | ||
| .map(std::fs::File::from) | ||
| .map(File::from) | ||
| .map_err(|err| io::Error::new(io::ErrorKind::Other, err)) | ||
| } | ||
|
|
||
| // Attempts to open the file with any of the possible negotiated_encodings in the | ||
| // preferred order. If none of the negotiated_encodings have a corresponding precompressed | ||
| // file the uncompressed file is used as a fallback. | ||
| async fn open_file_with_fallback( | ||
| mut path: PathBuf, | ||
| mut negotiated_encoding: Vec<(Encoding, QValue)>, | ||
| base_path: &Path, | ||
| follow_symlinks_outside_base: bool, | ||
| ) -> io::Result<(File, Option<Encoding>)> { | ||
| let (file, encoding) = loop { | ||
| // Get the preferred encoding among the negotiated ones. | ||
| let encoding = preferred_encoding(&mut path, &negotiated_encoding); | ||
| match (File::open(&path).await, encoding) { | ||
| let file = { | ||
| #[cfg(target_os = "linux")] | ||
| if follow_symlinks_outside_base { | ||
| File::open(&path).await | ||
| } else { | ||
| canonicalize_and_openat2(base_path, &path) | ||
| } | ||
| #[cfg(not(target_os = "linux"))] | ||
| File::open(&path).await | ||
| }; | ||
| match (file, encoding) { | ||
| (Ok(file), maybe_encoding) => break (file, maybe_encoding), | ||
| (Err(err), Some(encoding)) if err.kind() == io::ErrorKind::NotFound => { | ||
| // Remove the extension corresponding to a precompressed file (.gz, .br, .zz) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This should default to true. And relevant docs should mention the difference only if enabled.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thanks for the quick review, I guess I was a bit too paranoid. I have flipped this and tried to improve the docs.