Skip to content
Merged
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
31 changes: 10 additions & 21 deletions src/file_cache.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use crate::AppState;
use crate::filesystem::FileAccess;
use crate::webserver::ErrorWithStatus;
use crate::webserver::routing::FileStore;
use actix_web::http::StatusCode;
Expand Down Expand Up @@ -71,7 +72,8 @@ pub struct FileCache<T: AsyncFromStrWithState> {
}

impl<T: AsyncFromStrWithState> FileStore for FileCache<T> {
async fn contains(&self, path: &Path) -> anyhow::Result<bool> {
async fn contains(&self, access: FileAccess<'_>) -> anyhow::Result<bool> {
let path = access.path();
Ok(self.cache.read().await.contains_key(path) || self.static_files.contains_key(path))
}
}
Expand All @@ -97,35 +99,22 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
self.static_files.insert(path, Cached::new(contents));
}

/// Gets a file from the cache, or loads it from the file system if it's not there
/// This is a privileged operation; it should not be used for user-provided paths
pub async fn get(&self, app_state: &AppState, path: &Path) -> anyhow::Result<Arc<T>> {
self.get_with_privilege(app_state, path, true).await
}

pub fn get_static(&self, path: &Path) -> anyhow::Result<Arc<T>> {
self.static_files
.get(path)
.map(|cached| Arc::clone(&cached.content))
.ok_or_else(|| anyhow::anyhow!("File {} not found in static files", path.display()))
}

/// Gets a file from the cache, or loads it from the file system if it's not there
/// The privileged parameter is used to determine whether the access should be denied
/// if the file is in the sqlpage/ config directory
pub async fn get_with_privilege(
/// Gets a file from the cache, or loads it from the file system if it's not there.
pub async fn get(
&self,
app_state: &AppState,
path: &Path,
privileged: bool,
access: FileAccess<'_>,
) -> anyhow::Result<Arc<T>> {
let path = access.path();

log::trace!("Attempting to get from cache {}", path.display());
// Enforce the untrusted path guard before consulting the cache. A fresh cache
// entry (possibly populated by a privileged `run_sql` include) must never let an
// unprivileged request reach a reserved/private/traversal/absolute path.
if !privileged {
crate::filesystem::validate_unprivileged_path(path)?;
}
if let Some(cached) = self.cache.read().await.get(path) {
if !cached.needs_check(app_state.config.cache_stale_duration_ms()) {
log::trace!(
Expand All @@ -136,7 +125,7 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
}
match app_state
.file_system
.modified_since(app_state, path, cached.last_check_time(), privileged)
.modified_since(app_state, access, cached.last_check_time())
.await
{
Ok(false) => {
Expand All @@ -159,7 +148,7 @@ impl<T: AsyncFromStrWithState> FileCache<T> {
log::trace!("Loading and parsing {}", path.display());
let file_contents = app_state
.file_system
.read_to_string(app_state, path, privileged)
.read_to_string(app_state, access)
.await;

let parsed = match file_contents {
Expand Down
122 changes: 71 additions & 51 deletions src/filesystem.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,37 @@ use std::fmt::Write;
use std::io::ErrorKind;
use std::path::{Component, Path, PathBuf};

#[derive(Clone, Copy)]
#[must_use]
pub struct FileAccess<'a> {
path: &'a Path,
privileged: bool,
}

impl<'a> FileAccess<'a> {
/// Creates access for an untrusted, HTTP-facing path after validating it.
pub fn unprivileged(path: &'a Path) -> anyhow::Result<Self> {
validate_unprivileged_path(path)?;
Ok(Self {
path,
privileged: false,
})
}

/// Creates access for a trusted internal path.
pub const fn privileged(path: &'a Path) -> Self {
Self {
path,
privileged: true,
}
}

#[must_use]
pub const fn path(&self) -> &'a Path {
self.path
}
}

pub(crate) struct FileSystem {
local_root: PathBuf,
db_fs_queries: Option<DbFsQueries>,
Expand Down Expand Up @@ -39,11 +70,11 @@ impl FileSystem {
pub async fn modified_since(
&self,
app_state: &AppState,
path: &Path,
access: FileAccess<'_>,
since: DateTime<Utc>,
priviledged: bool,
) -> anyhow::Result<bool> {
let local_path = self.safe_local_path(app_state, path, priviledged)?;
let path = access.path();
let local_path = self.safe_local_path(app_state, access);
let local_result = file_modified_since_local(&local_path, since).await;
log::trace!(
"Local file {} modified since {since:?} ? {local_result:?}",
Expand All @@ -70,10 +101,10 @@ impl FileSystem {
pub async fn read_to_string(
&self,
app_state: &AppState,
path: &Path,
priviledged: bool,
access: FileAccess<'_>,
) -> anyhow::Result<String> {
let bytes = self.read_file(app_state, path, priviledged).await?;
let path = access.path();
let bytes = self.read_file(app_state, access).await?;
String::from_utf8(bytes).map_err(|utf8_err| {
let invalid_idx = utf8_err.utf8_error().valid_up_to();
let bytes = utf8_err.into_bytes();
Expand All @@ -95,16 +126,13 @@ impl FileSystem {
})
}

/**
* Priviledged files are the ones that are in sqlpage's config directory.
*/
pub async fn read_file(
&self,
app_state: &AppState,
path: &Path,
priviledged: bool,
access: FileAccess<'_>,
) -> anyhow::Result<Vec<u8>> {
let local_path = self.safe_local_path(app_state, path, priviledged)?;
let path = access.path();
let local_path = self.safe_local_path(app_state, access);
log::debug!(
"Reading file {} from {}",
path.display(),
Expand All @@ -130,13 +158,9 @@ impl FileSystem {
}
}

fn safe_local_path(
&self,
app_state: &AppState,
path: &Path,
priviledged: bool,
) -> anyhow::Result<PathBuf> {
if priviledged {
fn safe_local_path(&self, app_state: &AppState, access: FileAccess<'_>) -> PathBuf {
let path = access.path();
if access.privileged {
// Templates requests are always made to the static TEMPLATES_DIR, because this is where they are stored in the database
// but when serving them from the filesystem, we need to serve them from the `SQLPAGE_CONFIGURATION_DIRECTORY/templates` directory
if let Ok(template_path) = path.strip_prefix(TEMPLATES_DIR) {
Expand All @@ -150,32 +174,29 @@ impl FileSystem {
path.display(),
normalized.display()
);
return Ok(normalized);
return normalized;
}
} else {
validate_unprivileged_path(path)?;
}
Ok(self.local_root.join(path))
self.local_root.join(path)
}

pub(crate) async fn file_exists(
&self,
app_state: &AppState,
path: &Path,
access: FileAccess<'_>,
) -> anyhow::Result<bool> {
let local_exists = match self.safe_local_path(app_state, path, false) {
Ok(safe_path) => match tokio::fs::try_exists(safe_path).await {
Ok(exists) => exists,
Err(e) if is_path_missing_error(&e) => false,
Err(e) => {
let status = io_error_status(&e)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
return Err(e).with_status(status).with_context(|| {
format!("Unable to check if {} exists locally", path.display())
});
}
},
Err(e) => return Err(e),
let path = access.path();
let safe_path = self.safe_local_path(app_state, access);
let local_exists = match tokio::fs::try_exists(safe_path).await {
Ok(exists) => exists,
Err(e) if is_path_missing_error(&e) => false,
Err(e) => {
let status = io_error_status(&e)
.unwrap_or(actix_web::http::StatusCode::INTERNAL_SERVER_ERROR);
return Err(e).with_status(status).with_context(|| {
format!("Unable to check if {} exists locally", path.display())
});
}
};

// If not in local fs and we have db_fs, check database
Expand All @@ -192,16 +213,9 @@ impl FileSystem {
}
}

/// Rejects paths that an untrusted (unprivileged) HTTP request must never reach:
/// the reserved `sqlpage/` prefix, dotfiles, parent-directory traversal and
/// absolute/root paths.
///
/// This guard must be enforced on every unprivileged resolution path, including
/// before trusting a fresh cache hit. A trusted page may legitimately load such a
/// file with privilege via `sqlpage.run_sql(...)`, which populates the shared file
/// cache; without this check a later direct HTTP request could be served the cached
/// private file instead of being forbidden.
pub fn validate_unprivileged_path(path: &Path) -> anyhow::Result<()> {
/// Rejects paths that an untrusted HTTP request must never reach: the reserved
/// `sqlpage/` prefix, dotfiles, parent-directory traversal and absolute/root paths.
fn validate_unprivileged_path(path: &Path) -> anyhow::Result<()> {
for (i, component) in path.components().enumerate() {
if let Component::Normal(c) = component {
if i == 0 && c.eq_ignore_ascii_case("sqlpage") {
Expand Down Expand Up @@ -449,25 +463,31 @@ async fn test_sql_file_read_utf8() -> anyhow::Result<()> {

let fs = FileSystem::init("/", db).await;
let actual = fs
.read_to_string(&state, "unit test file.txt".as_ref(), false)
.read_to_string(
&state,
FileAccess::unprivileged("unit test file.txt".as_ref())?,
)
.await?;
assert_eq!(actual, "Héllö world! 😀");

let one_hour_ago = Utc::now() - chrono::Duration::hours(1);
let one_hour_future = Utc::now() + chrono::Duration::hours(1);

let was_modified = fs
.modified_since(&state, "unit test file.txt".as_ref(), one_hour_ago, false)
.modified_since(
&state,
FileAccess::unprivileged("unit test file.txt".as_ref())?,
one_hour_ago,
)
.await?;

assert!(was_modified, "File should be modified since one hour ago");

let was_modified = fs
.modified_since(
&state,
"unit test file.txt".as_ref(),
FileAccess::unprivileged("unit test file.txt".as_ref())?,
one_hour_future,
false,
)
.await?;
assert!(
Expand Down
3 changes: 2 additions & 1 deletion src/templates.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
use crate::app_config::AppConfig;
use crate::file_cache::AsyncFromStrWithState;
use crate::filesystem::FileAccess;
use crate::template_helpers::register_all_helpers;
use crate::{AppState, FileCache, TEMPLATES_DIR};
use async_trait::async_trait;
Expand Down Expand Up @@ -127,7 +128,7 @@ impl AllTemplates {
use anyhow::Context;
let path = Self::template_path(name);
self.split_templates
.get(app_state, &path)
.get(app_state, FileAccess::privileged(&path))
.await
.with_context(|| format!("Unable to get the component '{name}'"))
}
Expand Down
8 changes: 4 additions & 4 deletions src/webserver/database/sqlpage_functions/functions.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
use super::{ExecutionContext, RequestInfo};
use crate::filesystem::FileAccess;
use crate::webserver::{
ErrorWithStatus,
database::{
Expand Down Expand Up @@ -606,7 +607,7 @@ async fn read_file_bytes(request: &RequestInfo, path_str: &str) -> Result<Vec<u8
request
.app_state
.file_system
.read_file(&request.app_state, path, true)
.read_file(&request.app_state, FileAccess::privileged(path))
.await
} else {
tokio::fs::read(path)
Expand Down Expand Up @@ -735,10 +736,9 @@ async fn run_sql<'a>(
let app_state = &request.app_state;
let sql_file = app_state
.sql_file_cache
.get_with_privilege(
.get(
app_state,
std::path::Path::new(sql_file_path.as_ref()),
true,
FileAccess::privileged(std::path::Path::new(sql_file_path.as_ref())),
)
.instrument(run_sql_span.clone())
.await
Expand Down
11 changes: 8 additions & 3 deletions src/webserver/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ use super::https::make_auto_rustls_config;
use super::oidc::OidcMiddleware;
use super::response_writer::ResponseWriter;
use super::static_content;
use crate::filesystem::FileAccess;
use crate::webserver::routing::RoutingAction::{
CustomNotFound, Execute, NotFound, Redirect, Serve,
};
Expand Down Expand Up @@ -417,14 +418,16 @@ async fn process_sql_request(
let app_state: &web::Data<AppState> = req.app_data().expect("app_state");
let server_timing = ServerTiming::for_env(app_state.config.environment);

let access =
FileAccess::unprivileged(&sql_path).map_err(|e| anyhow_err_to_actix(e, app_state))?;
let sql_file = {
let span = tracing::info_span!(
"sqlpage.file.load",
{ otel::CODE_FILE_PATH } = %sql_path.display(),
);
app_state
.sql_file_cache
.get_with_privilege(app_state, &sql_path, false)
.get(app_state, access)
.instrument(span)
.await
.with_context(|| format!("Unable to read SQL file \"{}\"", sql_path.display()))
Expand All @@ -441,11 +444,13 @@ async fn serve_file(
if_modified_since: Option<IfModifiedSince>,
) -> actix_web::Result<HttpResponse> {
let path = strip_site_prefix(path, state);
let access =
FileAccess::unprivileged(path.as_ref()).map_err(|e| anyhow_err_to_actix(e, state))?;
if let Some(IfModifiedSince(date)) = if_modified_since {
let since = DateTime::<Utc>::from(SystemTime::from(date));
let modified = state
.file_system
.modified_since(state, path.as_ref(), since, false)
.modified_since(state, access, since)
.await
.with_context(|| format!("Unable to get modification time of file {path:?}"))
.map_err(|e| anyhow_err_to_actix(e, state))?;
Expand All @@ -455,7 +460,7 @@ async fn serve_file(
}
state
.file_system
.read_file(state, path.as_ref(), false)
.read_file(state, access)
.await
.with_context(|| format!("Unable to read file {path:?}"))
.map_err(|e| anyhow_err_to_actix(e, state))
Expand Down
Loading
Loading