Serve a content repo's .well-known/ at the site root
Everything a site has to publish for another program to find it lives under /.well-known/, and portal had no way to put a file there: the catch-all answered those paths 200 with the app shell, so a client probing for security.txt or matrix/server got HTML where it expected a document, and could not tell a missing file from a present one. The prefix is the one place this is safe to overlay. RFC 8615 reserves it, and a question's id comes from a file name under questions/, which cannot begin with a dot - so no file a repo ships can shadow a page. The path still goes through is_safe_site_path, so it cannot climb out of the repo either, and a miss is a real 404. Types: an extensionless file under the prefix is JSON, which is what matrix/server and nodeinfo are; .mobileconfig is the Apple profile type, without which iOS downloads a profile instead of offering to install it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015GeH3y8fwTaTudaD88P57o
This commit is contained in:
co-authored by
Claude Opus 5
parent
1a2c064d3a
commit
c9faaeb4a6
+115
-15
@@ -49,15 +49,56 @@ pub struct GiteaRepoQuery {
|
||||
pub async fn site_asset_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
serve_repo_file(&state, &path, false).await
|
||||
}
|
||||
|
||||
/// `GET /.well-known/{*path}` - the content repo overlaid on the one part
|
||||
/// of the URL space that can never collide with a question: RFC 8615
|
||||
/// reserves `/.well-known/`, and a question's id is derived from a file
|
||||
/// name under `questions/`, which cannot begin with a dot. A repo ships
|
||||
/// `.well-known/security.txt`, `.well-known/assetlinks.json`,
|
||||
/// `.well-known/matrix/server` and so on at the path they are served at,
|
||||
/// and a miss is a real 404 rather than the app shell answering 200 with
|
||||
/// an HTML page - which is what a client probing for one of these would
|
||||
/// otherwise have to parse.
|
||||
///
|
||||
/// Paths Caddy claims in front of portal (mail autoconfiguration, the
|
||||
/// CalDAV and CardDAV redirects) never reach here.
|
||||
#[cfg(feature = "ssr")]
|
||||
pub async fn well_known_handler(
|
||||
axum::extract::State(state): axum::extract::State<crate::server::AppState>,
|
||||
axum::extract::Path(path): axum::extract::Path<String>,
|
||||
) -> axum::response::Response {
|
||||
serve_repo_file(&state, &format!(".well-known/{path}"), true).await
|
||||
}
|
||||
|
||||
/// Proxies one repo-relative file from the content repo's default branch.
|
||||
/// `well_known` only changes what an extensionless file is typed as: under
|
||||
/// `/.well-known/` those are JSON by convention (`matrix/server`,
|
||||
/// `nodeinfo`), while elsewhere a file with no extension is an unknown.
|
||||
#[cfg(feature = "ssr")]
|
||||
async fn serve_repo_file(
|
||||
state: &crate::server::AppState,
|
||||
path: &str,
|
||||
well_known: bool,
|
||||
) -> axum::response::Response {
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
|
||||
if !is_safe_site_path(&path) {
|
||||
if !is_safe_site_path(path) {
|
||||
return (StatusCode::BAD_REQUEST, "invalid asset path").into_response();
|
||||
}
|
||||
let url = format!("{}/{path}?ref={}", state.content_raw_base, state.content_branch);
|
||||
let resp = match crate::http::client().get(&url).send().await.and_then(|r| r.error_for_status()) {
|
||||
let url = format!(
|
||||
"{}/{path}?ref={}",
|
||||
state.content_raw_base, state.content_branch
|
||||
);
|
||||
let resp = match crate::http::client()
|
||||
.get(&url)
|
||||
.send()
|
||||
.await
|
||||
.and_then(|r| r.error_for_status())
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
tracing::warn!("site asset {path}: {e}");
|
||||
@@ -79,12 +120,26 @@ pub async fn site_asset_handler(
|
||||
Some("avif") => "image/avif",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
Some("txt") => "text/plain; charset=utf-8",
|
||||
Some("xml") => "application/xml; charset=utf-8",
|
||||
Some("webmanifest") => "application/manifest+json",
|
||||
// An Apple configuration profile only offers to install when it
|
||||
// arrives under this type; anything else is just a download.
|
||||
Some("mobileconfig") => "application/x-apple-aspen-config",
|
||||
// A file with no extension at all: rsplit yields the whole name.
|
||||
_ if well_known && !path.rsplit('/').next().unwrap_or_default().contains('.') => {
|
||||
"application/json; charset=utf-8"
|
||||
}
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
// Photographs are large and do not change under a name, so a
|
||||
// browser may keep them for an hour; everything else revalidates,
|
||||
// so a content push shows on reload.
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") { "public, max-age=3600" } else { "no-cache" };
|
||||
let cache = if matches!(mime, "image/webp" | "image/avif" | "image/jpeg") {
|
||||
"public, max-age=3600"
|
||||
} else {
|
||||
"no-cache"
|
||||
};
|
||||
(
|
||||
[(header::CONTENT_TYPE, mime), (header::CACHE_CONTROL, cache)],
|
||||
bytes,
|
||||
@@ -114,14 +169,22 @@ pub async fn gitea_repo_handler(
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.')
|
||||
};
|
||||
if !is_safe_segment(&query.owner) || !is_safe_segment(&query.repo) {
|
||||
return Err((axum::http::StatusCode::BAD_REQUEST, "invalid owner/repo".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::BAD_REQUEST,
|
||||
"invalid owner/repo".to_string(),
|
||||
));
|
||||
}
|
||||
let GiteaRepoQuery { owner, repo } = query;
|
||||
|
||||
let api_url = format!("{}/api/v1/repos/{owner}/{repo}", state.gitea_base);
|
||||
let body = crate::http::get_text(&api_url, crate::http::GITEA_CAP)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("fetching {api_url}: {e}")))?;
|
||||
.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("fetching {api_url}: {e}"),
|
||||
)
|
||||
})?;
|
||||
let json: serde_json::Value = serde_json::from_str(&body).map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
@@ -168,27 +231,38 @@ pub async fn automation_kv_handler(
|
||||
.and_then(|v| v.strip_prefix("Bearer "))
|
||||
.unwrap_or("");
|
||||
if expected.is_empty() || presented != expected {
|
||||
return Err((axum::http::StatusCode::UNAUTHORIZED, "unauthorized".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::UNAUTHORIZED,
|
||||
"unauthorized".to_string(),
|
||||
));
|
||||
}
|
||||
// The token authenticates the automation service; what it may read
|
||||
// is the policy's to say, like every other principal.
|
||||
let decision = crate::access::decide_and_publish(
|
||||
&state.nats,
|
||||
&state.access.load(),
|
||||
&crate::access::Principal::Service { name: "automation".to_string() },
|
||||
&crate::access::Principal::Service {
|
||||
name: "automation".to_string(),
|
||||
},
|
||||
crate::access::Action::AutomationRead,
|
||||
&crate::access::Resource::Bucket { name: bucket.clone() },
|
||||
&crate::access::Resource::Bucket {
|
||||
name: bucket.clone(),
|
||||
},
|
||||
serde_json::json!({}),
|
||||
);
|
||||
if !decision.allow {
|
||||
return Err((axum::http::StatusCode::FORBIDDEN, "not authorized".to_string()));
|
||||
return Err((
|
||||
axum::http::StatusCode::FORBIDDEN,
|
||||
"not authorized".to_string(),
|
||||
));
|
||||
}
|
||||
|
||||
let store = state
|
||||
.jetstream
|
||||
.get_key_value(&bucket)
|
||||
.await
|
||||
.map_err(|e| (axum::http::StatusCode::BAD_GATEWAY, format!("bucket unavailable: {e}")))?;
|
||||
let store = state.jetstream.get_key_value(&bucket).await.map_err(|e| {
|
||||
(
|
||||
axum::http::StatusCode::BAD_GATEWAY,
|
||||
format!("bucket unavailable: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
use futures::TryStreamExt;
|
||||
let keys: Vec<String> = store
|
||||
@@ -209,3 +283,29 @@ pub async fn automation_kv_handler(
|
||||
Ok(axum::Json(serde_json::Value::Array(items)))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The overlay's whole safety argument: `/.well-known/` is reserved by
|
||||
/// RFC 8615 and a question's id comes from a file under `questions/`, so
|
||||
/// a repo file served at the site root cannot shadow a page - and the
|
||||
/// path it is fetched by still cannot climb out of the repo.
|
||||
#[test]
|
||||
fn well_known_paths_stay_inside_the_repo() {
|
||||
for path in [
|
||||
".well-known/security.txt",
|
||||
".well-known/matrix/server",
|
||||
".well-known/assetlinks.json",
|
||||
] {
|
||||
assert!(is_safe_site_path(path), "{path} should be servable");
|
||||
}
|
||||
for path in [
|
||||
".well-known/../questions/index.yaml",
|
||||
".well-known//escape",
|
||||
"/.well-known/security.txt",
|
||||
] {
|
||||
assert!(!is_safe_site_path(path), "{path} should be refused");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user