Files
fluxer/fluxer_app_proxy/src/routes/spa_index.rs
T

1054 lines
37 KiB
Rust

// SPDX-License-Identifier: AGPL-3.0-or-later
use crate::bootstrap::{build_bootstrap_script, inject_bootstrap};
use crate::config::HttpEndpoint;
use crate::csp::{RuntimeCspSources, generate_nonce};
use crate::discovery_cache::{DiscoveryResponse, discovery_endpoint};
use crate::geoip::build_geoip_response;
use crate::state::{
AppProxyBudgets, AppState, MAX_RENDERED_SPA_INDEX_BYTES, MAX_SPA_INDEX_BYTES,
read_bounded_text_file,
};
use axum::{
body::{Body, Bytes},
extract::{Request, State},
http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header},
response::{IntoResponse, Response},
};
use std::path::Path;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use super::assets_proxy::serve_local_asset;
use super::file_stream::stream_file;
use super::spa_static::{CORS_ALLOW_ANY_VALUE, guess_mime, is_font_mime};
const ACCEPT_CH_VALUE: &str = "DPR, Sec-CH-DPR, Sec-CH-Width, Save-Data, ECT, Downlink";
const CRITICAL_CH_VALUE: &str = "Sec-CH-DPR, Sec-CH-Width, Save-Data";
const DEV_NO_STORE_CACHE_CONTROL: &str = "no-store, no-cache, must-revalidate, max-age=0";
pub async fn spa_catch_all(
State(state): State<AppState>,
headers: HeaderMap,
request: Request,
) -> Response {
let request_path = request.uri().path();
if let Some(cache_control) = static_root_file_cache_control(request_path) {
return serve_static_file(
&state.budgets,
&state.config.static_dir,
request_path,
cache_control,
&headers,
)
.await;
}
if is_static_asset_path(request_path) {
return serve_local_asset(
&state.budgets,
&state.config.static_dir,
request_path.trim_start_matches('/'),
&headers,
state.csp.asset_header(),
)
.await;
}
serve_spa_index(&state, &headers).await
}
const CRAWL_CONTROL_CACHE_CONTROL: &str = "public, max-age=300, must-revalidate";
const STATIC_ROOT_FILES: &[(&str, &str)] = &[("/robots.txt", CRAWL_CONTROL_CACHE_CONTROL)];
const STATIC_ASSET_PREFIXES: &[&str] = &[
"/avatars/",
"/badges/",
"/desktop/",
"/embeds/",
"/emoji/",
"/libs/",
"/marketing/",
"/web/",
];
fn static_root_file_cache_control(request_path: &str) -> Option<&'static str> {
STATIC_ROOT_FILES
.iter()
.find(|(candidate, _)| request_path.eq_ignore_ascii_case(candidate))
.map(|(_, cache_control)| *cache_control)
}
fn is_static_asset_path(request_path: &str) -> bool {
STATIC_ASSET_PREFIXES
.iter()
.any(|prefix| request_path.starts_with(prefix))
}
async fn serve_static_file(
budgets: &AppProxyBudgets,
static_dir: &str,
request_path: &str,
cache_control: &'static str,
request_headers: &HeaderMap,
) -> Response {
let Ok(_read_slot) = budgets.local_read_slots.try_acquire() else {
return super::capacity_refused_response();
};
let file_path = Path::new(static_dir).join(request_path.trim_start_matches('/'));
let resolved = match tokio::fs::canonicalize(&file_path).await {
Ok(p) => p,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let base = match tokio::fs::canonicalize(static_dir).await {
Ok(p) => p,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
if !resolved.starts_with(&base) {
tracing::warn!(path = request_path, "directory traversal attempt blocked");
return StatusCode::NOT_FOUND.into_response();
}
let mut response = match stream_file(&resolved, request_headers, None).await {
Ok(response) => response,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return StatusCode::NOT_FOUND.into_response();
}
Err(err) => {
tracing::error!(path = request_path, %err, "failed to read static file");
return (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error").into_response();
}
};
let mime_type = guess_mime(request_path);
if let Ok(ct) = HeaderValue::from_str(mime_type) {
response.headers_mut().insert(header::CONTENT_TYPE, ct);
}
if is_font_mime(mime_type) {
response.headers_mut().insert(
header::ACCESS_CONTROL_ALLOW_ORIGIN,
HeaderValue::from_static(CORS_ALLOW_ANY_VALUE),
);
}
response.headers_mut().insert(
header::CACHE_CONTROL,
HeaderValue::from_static(cache_control),
);
response
}
async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
let should_bust_dev_assets = state.config.index_upstream_url.is_some();
let discovery = match refresh_discovery_for_spa(state).await {
Some(d) => d,
None => {
tracing::error!("discovery cache empty, cannot serve SPA");
return StatusCode::SERVICE_UNAVAILABLE.into_response();
}
};
let nonce = generate_nonce();
let runtime_csp_sources = build_runtime_csp_sources(state, &discovery);
let static_cdn_endpoint = runtime_csp_sources
.static_cdn_endpoint
.as_ref()
.map_or("", HttpEndpoint::as_str);
let media_endpoint = runtime_csp_sources
.media_endpoint
.as_ref()
.map_or("", HttpEndpoint::as_str);
let csp = state.csp.spa_header(&nonce, &runtime_csp_sources);
let geoip = build_geoip_response(state.geoip.lookup(headers));
let script_tag = build_bootstrap_script(&state.config, &discovery, &geoip, &nonce);
let raw_html = match load_spa_index_html(state).await {
Ok(content) => content,
Err(response) => return response,
};
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
let html = match render_spa_document(
&raw_html,
&nonce,
&script_tag,
static_cdn_endpoint,
media_endpoint,
dev_buster.as_deref(),
) {
Ok(html) => html,
Err(error) => {
tracing::error!(%error, "failed to render SPA document within its size limit");
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
}
};
let html = html.into_boxed_str();
build_spa_response(html, csp, should_bust_dev_assets)
}
#[derive(Debug)]
struct SpaDocumentSizeLimitError {
attempted_bytes: usize,
}
impl std::fmt::Display for SpaDocumentSizeLimitError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
formatter,
"rendered SPA document would be {} bytes, exceeding the {MAX_RENDERED_SPA_INDEX_BYTES} byte limit",
self.attempted_bytes
)
}
}
impl std::error::Error for SpaDocumentSizeLimitError {}
fn bounded_document(document: String) -> Result<String, SpaDocumentSizeLimitError> {
if document.len() > MAX_RENDERED_SPA_INDEX_BYTES {
return Err(SpaDocumentSizeLimitError {
attempted_bytes: document.len(),
});
}
Ok(document)
}
fn render_spa_document(
html: &str,
nonce: &str,
script_tag: &str,
static_cdn_endpoint: &str,
media_endpoint: &str,
dev_asset_cache_buster: Option<&str>,
) -> Result<String, SpaDocumentSizeLimitError> {
let mut document = bounded_document(inject_bootstrap(
html,
nonce,
script_tag,
static_cdn_endpoint,
media_endpoint,
))?;
if let Some(buster) = dev_asset_cache_buster {
document = bounded_document(append_dev_asset_cache_buster(&document, buster))?;
}
Ok(document)
}
async fn refresh_discovery_for_spa(state: &AppState) -> Option<DiscoveryResponse> {
state
.discovery_cache
.get_or_cold_start(&state.http_client, &state.config.discovery_upstream_url)
.await
}
fn build_runtime_csp_sources(state: &AppState, discovery: &DiscoveryResponse) -> RuntimeCspSources {
RuntimeCspSources {
static_cdn_endpoint: discovery_endpoint(discovery, "static_cdn")
.or_else(|| state.config.static_cdn_endpoint.clone()),
media_endpoint: discovery_endpoint(discovery, "media"),
s3_public_endpoint: state.config.s3_public_endpoint.clone(),
s3_uploads_endpoint: state.config.s3_uploads_endpoint.clone(),
branding_image_origins: branding_image_origins(discovery),
}
}
const BRANDING_IMAGE_KEYS: &[&str] = &[
"icon_url",
"symbol_url",
"logo_url",
"wordmark_url",
"favicon_url",
];
fn branding_image_origins(discovery: &DiscoveryResponse) -> Vec<HttpEndpoint> {
let Some(branding) = discovery
.data
.get("app_public")
.and_then(|app_public| app_public.get("branding"))
else {
return Vec::new();
};
let mut origins: Vec<HttpEndpoint> = Vec::new();
for key in BRANDING_IMAGE_KEYS {
let Some(raw) = branding
.get(*key)
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
else {
continue;
};
let origin = match HttpEndpoint::parse(key, raw) {
Ok(origin) => origin,
Err(error) => {
tracing::warn!(%error, "ignoring invalid branding image origin");
continue;
}
};
if !origins
.iter()
.any(|existing| existing.csp_origin() == origin.csp_origin())
{
origins.push(origin);
}
}
origins
}
#[allow(clippy::result_large_err)]
async fn load_spa_index_html(state: &AppState) -> Result<String, Response> {
if let Some(index_upstream_url) = &state.config.index_upstream_url {
let response = state
.http_client
.get(index_upstream_url.as_url().clone())
.timeout(Duration::from_secs(10))
.send()
.await
.map_err(|err| {
tracing::error!(url = %index_upstream_url, %err, "failed to fetch upstream index.html");
StatusCode::BAD_GATEWAY.into_response()
})?;
if !response.status().is_success() {
let status = response.status();
tracing::error!(url = %index_upstream_url, %status, "upstream index.html returned non-success status");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
if response
.content_length()
.is_some_and(|length| length > MAX_SPA_INDEX_BYTES as u64)
{
tracing::error!(url = %index_upstream_url, "upstream index.html exceeds the size limit");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
let mut response = response;
let mut bytes: Vec<u8> = Vec::new();
loop {
let chunk = response.chunk().await.map_err(|err| {
tracing::error!(url = %index_upstream_url, %err, "failed to read upstream index.html body");
StatusCode::BAD_GATEWAY.into_response()
})?;
let Some(chunk) = chunk else {
break;
};
if chunk.len() > MAX_SPA_INDEX_BYTES - bytes.len() {
tracing::error!(url = %index_upstream_url, "upstream index.html exceeds the size limit");
return Err(StatusCode::BAD_GATEWAY.into_response());
}
bytes.extend_from_slice(&chunk);
}
return String::from_utf8(bytes).map_err(|err| {
tracing::error!(url = %index_upstream_url, %err, "upstream index.html is not valid UTF-8");
StatusCode::BAD_GATEWAY.into_response()
});
}
if let Some(cached) = &state.index_html {
return Ok(cached.to_string());
}
let Ok(_read_slot) = state.budgets.local_read_slots.try_acquire() else {
return Err(super::capacity_refused_response());
};
let index_path = Path::new(&state.config.static_dir).join("index.html");
read_bounded_text_file(&index_path, MAX_SPA_INDEX_BYTES)
.await
.map_err(|err| {
tracing::error!(path = ?index_path, %err, "failed to read index.html");
StatusCode::INTERNAL_SERVER_ERROR.into_response()
})
}
struct SpaDocumentBody {
html: Box<str>,
}
impl AsRef<[u8]> for SpaDocumentBody {
fn as_ref(&self) -> &[u8] {
self.html.as_bytes()
}
}
fn build_spa_response(html: Box<str>, csp: HeaderValue, dev_no_store: bool) -> Response {
let body = Bytes::from_owner(SpaDocumentBody { html });
let mut response = Response::new(Body::from(body));
let headers = response.headers_mut();
headers.insert(header::CONTENT_SECURITY_POLICY, csp);
headers.insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
);
if dev_no_store {
headers.insert(
header::CACHE_CONTROL,
HeaderValue::from_static(DEV_NO_STORE_CACHE_CONTROL),
);
headers.insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
headers.insert(header::EXPIRES, HeaderValue::from_static("0"));
headers.insert(
HeaderName::from_static("cdn-cache-control"),
HeaderValue::from_static("no-store"),
);
headers.insert(
HeaderName::from_static("cloudflare-cdn-cache-control"),
HeaderValue::from_static("no-store"),
);
} else {
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
}
super::set_security_headers(headers);
headers.insert(
axum::http::HeaderName::from_static("accept-ch"),
HeaderValue::from_static(ACCEPT_CH_VALUE),
);
headers.insert(
axum::http::HeaderName::from_static("critical-ch"),
HeaderValue::from_static(CRITICAL_CH_VALUE),
);
response
}
fn current_dev_asset_cache_buster() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.as_millis().to_string())
.unwrap_or_else(|_| "0".to_owned())
}
fn append_dev_asset_cache_buster(html: &str, buster: &str) -> String {
let html = append_dev_asset_cache_buster_for_attr(html, "src", '"', buster);
let html = append_dev_asset_cache_buster_for_attr(&html, "src", '\'', buster);
let html = append_dev_asset_cache_buster_for_attr(&html, "href", '"', buster);
append_dev_asset_cache_buster_for_attr(&html, "href", '\'', buster)
}
fn append_dev_asset_cache_buster_for_attr(
html: &str,
attr: &str,
quote: char,
buster: &str,
) -> String {
let needle = format!("{attr}={quote}");
let mut rest = html;
let mut output = String::with_capacity(html.len() + 128);
while let Some(index) = rest.find(&needle) {
output.push_str(&rest[..index + needle.len()]);
rest = &rest[index + needle.len()..];
let Some(end_index) = rest.find(quote) else {
output.push_str(rest);
return output;
};
let value = &rest[..end_index];
if should_cache_bust_asset_url(value) {
output.push_str(&append_cache_buster_query(value, buster));
} else {
output.push_str(value);
}
output.push(quote);
rest = &rest[end_index + quote.len_utf8()..];
}
output.push_str(rest);
output
}
fn should_cache_bust_asset_url(value: &str) -> bool {
let value = value.trim();
if value.is_empty()
|| value.starts_with('#')
|| value.starts_with("data:")
|| value.starts_with("blob:")
|| value.starts_with("javascript:")
{
return false;
}
let path = value
.split(['?', '#'])
.next()
.unwrap_or(value)
.to_ascii_lowercase();
if path.starts_with("/assets/") || path.starts_with("assets/") || path.contains("/assets/") {
return !has_version_marker(value, &path);
}
if path.ends_with("/sw.js")
|| path == "/sw.js"
|| path.ends_with("/manifest.json")
|| path == "/manifest.json"
|| path.ends_with("/browserconfig.xml")
|| path == "/browserconfig.xml"
{
return true;
}
[
".css", ".js", ".mjs", ".wasm", ".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".svg",
".woff", ".woff2", ".ttf", ".eot",
]
.iter()
.any(|extension| path.ends_with(extension))
}
fn has_version_marker(value: &str, path: &str) -> bool {
let filename = path.rsplit('/').next().unwrap_or(path);
let stem = filename
.rsplit_once('.')
.map(|(stem, _)| stem)
.unwrap_or(filename);
if stem.split(['.', '-', '_']).any(is_hex_hash) {
return true;
}
let query = value
.split_once('#')
.map(|(before_hash, _)| before_hash)
.unwrap_or(value)
.split_once('?')
.map(|(_, query)| query)
.unwrap_or("");
query
.split('&')
.map(|part| part.split_once('=').map(|(key, _)| key).unwrap_or(part))
.any(|key| key != "_" && is_hex_hash(key))
}
fn is_hex_hash(value: &str) -> bool {
value.len() >= 8 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
}
fn append_cache_buster_query(value: &str, buster: &str) -> String {
let (before_hash, hash) = value
.split_once('#')
.map(|(before_hash, hash)| (before_hash, Some(hash)))
.unwrap_or((value, None));
let separator = if before_hash.contains('?') { '&' } else { '?' };
match hash {
Some(hash) => format!("{before_hash}{separator}_={buster}#{hash}"),
None => format!("{before_hash}{separator}_={buster}"),
}
}
#[cfg(test)]
mod tests {
use super::super::spa_static::LONG_LIVED_ASSET_CACHE_CONTROL;
use super::*;
fn is_static_root_file(request_path: &str) -> bool {
static_root_file_cache_control(request_path).is_some()
}
use crate::config::{AppProxyConfig, ReleaseChannel};
use crate::discovery_cache::DiscoveryCache;
use axum::Router;
use axum::body::Body;
use fluxer_common::config::GeoipSourceConfig;
use fluxer_common::geoip::{GeoipConfig, GeoipResolver};
use std::sync::Arc;
#[test]
fn dev_asset_cache_buster_rewrites_script_and_link_assets() {
let html = r#"<link rel="preconnect" href="https://example.test"><link href="/assets/main.css?abcdef1234567890"><script src="https://example.test/assets/main.abcdef1234567890.js"></script><script src="/assets/unversioned.js"></script><link rel="manifest" href="/manifest.json">"#;
let rewritten = append_dev_asset_cache_buster(html, "123");
assert!(rewritten.contains(r#"href="https://example.test""#));
assert!(rewritten.contains(r#"href="/assets/main.css?abcdef1234567890""#));
assert!(
rewritten.contains(r#"src="https://example.test/assets/main.abcdef1234567890.js""#)
);
assert!(rewritten.contains(r#"src="/assets/unversioned.js?_=123""#));
assert!(rewritten.contains(r#"href="/manifest.json?_=123""#));
}
#[test]
fn dev_asset_cache_buster_preserves_hash_fragments() {
assert_eq!(
append_cache_buster_query("/assets/main.js?hash#module", "123"),
"/assets/main.js?hash&_=123#module"
);
}
#[test]
fn dev_asset_cache_buster_skips_non_asset_urls() {
assert!(!should_cache_bust_asset_url(
"https://example.test/channels/@me"
));
assert!(!should_cache_bust_asset_url("data:image/png;base64,abc"));
assert!(should_cache_bust_asset_url(
"https://example.test/web/favicon-32x32.png"
));
}
#[test]
fn only_declared_static_root_files_bypass_the_spa_document() {
assert!(is_static_root_file("/robots.txt"));
assert!(!is_static_root_file("/index.html"));
assert!(!is_static_root_file("/channels/@me"));
}
#[test]
fn spa_routes_containing_a_dot_still_render_the_document() {
assert!(!is_static_root_file("/theme/my.custom.theme"));
assert!(!is_static_root_file("/invite/abc.def"));
assert!(!is_static_root_file("/users/1.2.3"));
}
const SHELL_WITH_A_NONCE_HOLE: &str = r#"<!doctype html><html><head><title>Fluxer</title><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script><script src="/assets/app.js"></script></head><body></body></html>"#;
#[test]
fn the_rendered_document_always_carries_the_bootstrap_and_a_real_nonce() {
let rendered = render_spa_document(
SHELL_WITH_A_NONCE_HOLE,
"reqnonce",
"<script>booted</script>",
"https://static.example.test",
"",
None,
)
.expect("test SPA document must render within its size limit");
assert!(!rendered.contains("{{CSP_NONCE_PLACEHOLDER}}"));
assert!(rendered.contains(r#"nonce="reqnonce""#));
assert!(rendered.contains("<script>booted</script>"));
}
#[test]
fn the_dev_cache_buster_reaches_the_rendered_document_only_when_supplied() {
let busted = render_spa_document(
SHELL_WITH_A_NONCE_HOLE,
"reqnonce",
"<script>booted</script>",
"",
"",
Some("9911"),
)
.expect("test SPA document must render within its size limit");
let untouched = render_spa_document(
SHELL_WITH_A_NONCE_HOLE,
"reqnonce",
"<script>booted</script>",
"",
"",
None,
)
.expect("test SPA document must render within its size limit");
assert!(busted.contains(r#"src="/assets/app.js?_=9911""#));
assert!(untouched.contains(r#"src="/assets/app.js""#));
assert!(!untouched.contains("_=9911"));
}
const SHELL_WITH_ENDPOINT_HOLES: &str = r#"<!doctype html><html><head><title>Fluxer</title><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}" crossorigin>
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
<link rel="icon" type="image/png" sizes="32x32" href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png"><link rel="apple-touch-icon" sizes="180x180" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png"><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script><script src="/assets/app.js"></script></head><body></body></html>"#;
#[test]
fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() {
let rendered = render_spa_document(
SHELL_WITH_ENDPOINT_HOLES,
"reqnonce",
"<script>booted</script>",
"https://cdn.example.test/",
"https://media.example.test",
None,
)
.expect("test SPA document must render within its size limit");
assert!(
rendered
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#),
"the static CDN argument never reached the anonymous preconnect"
);
assert!(
rendered.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#),
"the static CDN argument never reached the credentialed preconnect"
);
assert!(
rendered.contains(r#"href="https://cdn.example.test/web/favicon-32x32.png""#),
"the favicon href was not resolved against the static CDN argument"
);
assert!(
rendered.contains(r#"href="https://cdn.example.test/web/apple-touch-icon.png""#),
"the touch-icon href was not resolved against the static CDN argument"
);
assert!(!rendered.contains("{{STATIC_CDN_ENDPOINT}}"));
assert_eq!(rendered.matches("preconnect").count(), 3);
}
#[test]
fn the_media_argument_is_resolved_and_weighed_against_the_static_cdn() {
let distinct = render_spa_document(
SHELL_WITH_ENDPOINT_HOLES,
"reqnonce",
"<script>booted</script>",
"https://cdn.example.test",
"https://media.example.test/",
None,
)
.expect("test SPA document must render within its size limit");
assert!(
distinct.contains(r#"<link rel="preconnect" href="https://media.example.test">"#),
"the media argument never reached the media preconnect"
);
assert!(!distinct.contains("{{MEDIA_ENDPOINT}}"));
assert_eq!(distinct.matches("preconnect").count(), 3);
let shared = render_spa_document(
SHELL_WITH_ENDPOINT_HOLES,
"reqnonce",
"<script>booted</script>",
"https://cdn.example.test",
"https://cdn.example.test",
None,
)
.expect("test SPA document must render within its size limit");
assert!(
shared.contains(r#"<link rel="preconnect" href="https://cdn.example.test">"#),
"the static preconnects must survive a media endpoint that collapses onto them"
);
assert_eq!(
shared.matches("preconnect").count(),
2,
"a media endpoint equal to the static CDN must not warm a third socket"
);
}
const DISCOVERY_BODY_WITH_BOTH_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test","endpoints":{"static_cdn":"https://cdn.example.test","media":"https://media.example.test"}}"#;
const DISCOVERY_BODY_WITHOUT_ENDPOINTS: &str = r#"{"api_code_version":"proxy-test"}"#;
async fn spawn_local_origin(payload: &'static str, content_type: &'static str) -> String {
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
let router = Router::new().fallback(move || async move {
let mut response = Response::new(Body::from(payload));
response
.headers_mut()
.insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type));
response
});
tokio::spawn(async move {
axum::serve(listener, router).await.unwrap();
});
format!("http://{addr}/")
}
async fn spa_state_serving(channel: ReleaseChannel, cached_shell: Option<&str>) -> AppState {
assemble_spa_state(
channel,
cached_shell,
DISCOVERY_BODY_WITH_BOTH_ENDPOINTS,
None,
None,
)
.await
}
async fn spa_state_reading_its_shell_from(index_upstream_url: String) -> AppState {
assemble_spa_state(
ReleaseChannel::Stable,
None,
DISCOVERY_BODY_WITH_BOTH_ENDPOINTS,
None,
Some(index_upstream_url),
)
.await
}
async fn spa_state_without_discovered_endpoints(static_cdn_fallback: Option<&str>) -> AppState {
assemble_spa_state(
ReleaseChannel::Canary,
Some(SHELL_WITH_ENDPOINT_HOLES),
DISCOVERY_BODY_WITHOUT_ENDPOINTS,
static_cdn_fallback,
None,
)
.await
}
async fn assemble_spa_state(
channel: ReleaseChannel,
cached_shell: Option<&str>,
discovery_body: &'static str,
static_cdn_fallback: Option<&str>,
index_upstream_url: Option<String>,
) -> AppState {
let discovery_upstream_url = spawn_local_origin(discovery_body, "application/json").await;
let mut config = AppProxyConfig::from_env();
config.release_channel = channel;
config.index_upstream_url = index_upstream_url.map(|url| {
crate::config::HttpUrl::parse("TEST_INDEX_UPSTREAM_URL", &url)
.expect("test index upstream URL must be a valid HTTP URL")
});
config.static_cdn_endpoint = static_cdn_fallback.map(|endpoint| {
HttpEndpoint::parse("TEST_STATIC_CDN_ENDPOINT", endpoint)
.expect("test static CDN endpoint must be a valid HTTP endpoint")
});
config.trust_client_ip_header = false;
config.discovery_upstream_url = discovery_upstream_url;
let csp = Arc::new(
crate::csp::CompiledCspPolicy::from_config(&config)
.expect("the test configuration must compile to a valid CSP"),
);
AppState {
config: Arc::new(config),
csp,
http_client: reqwest::Client::new(),
discovery_cache: Arc::new(DiscoveryCache::new()),
geoip: Arc::new(GeoipResolver::from_config(&GeoipConfig {
geoip_source: GeoipSourceConfig::Filesystem {
maxmind_db_path: None,
},
geoip_s3_config: None,
trust_client_ip_header: false,
client_ip_header_name: "x-forwarded-for".to_owned(),
})),
index_html: cached_shell.map(Arc::from),
budgets: crate::state::AppProxyBudgets::default(),
}
}
fn nonce_granted_by(response: &Response) -> String {
let policy = response
.headers()
.get(header::CONTENT_SECURITY_POLICY)
.expect("the document was served without a content security policy")
.to_str()
.unwrap();
let opening = policy
.find("'nonce-")
.expect("the content security policy granted no nonce at all");
let remainder = &policy[opening + "'nonce-".len()..];
let closing = remainder
.find('\'')
.expect("the content security policy left its nonce source unterminated");
remainder[..closing].to_owned()
}
async fn read_document(response: Response) -> String {
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
String::from_utf8(bytes.to_vec()).unwrap()
}
#[tokio::test]
async fn the_live_branch_serves_a_rendered_document_and_not_the_raw_shell() {
let state =
spa_state_serving(ReleaseChannel::Canary, Some(SHELL_WITH_ENDPOINT_HOLES)).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
let granted_nonce = nonce_granted_by(&response);
let served = read_document(response).await;
assert!(
!served.contains("{{CSP_NONCE_PLACEHOLDER}}"),
"the live branch shipped an unfilled nonce hole"
);
assert!(
served.contains(&format!(
r#"<script nonce="{granted_nonce}">window.__FLUXER_BOOTSTRAP__"#
)),
"the live bootstrap was not granted the nonce its own policy header carries"
);
assert_eq!(
served
.matches(&format!(r#"nonce="{granted_nonce}""#))
.count(),
served.matches(r#"nonce=""#).count(),
"the live document carries a nonce its own policy header never granted"
);
assert!(
!served.contains("{{STATIC_CDN_ENDPOINT}}"),
"the live branch shipped an unresolved static CDN hole"
);
assert!(
!served.contains("{{MEDIA_ENDPOINT}}"),
"the live branch shipped an unresolved media hole"
);
assert!(
served.contains("window.__FLUXER_BOOTSTRAP__"),
"the live branch shipped without a bootstrap script"
);
assert!(
served
.contains(r#"<link rel="preconnect" href="https://cdn.example.test" crossorigin>"#),
"the discovered static CDN never reached the served document"
);
assert!(
served.contains(r#"<link rel="preconnect" href="https://media.example.test">"#),
"the discovered media endpoint never reached the served document"
);
}
#[tokio::test]
async fn a_crawl_control_document_is_never_served_with_the_asset_lifetime() {
let root = std::env::temp_dir().join(format!(
"fluxer-app-proxy-crawl-control-{}",
std::process::id()
));
tokio::fs::create_dir_all(&root).await.unwrap();
tokio::fs::write(root.join("robots.txt"), "User-agent: *\nDisallow:\n")
.await
.unwrap();
let static_dir = root.to_str().unwrap();
let policy = static_root_file_cache_control("/robots.txt")
.expect("robots.txt is no longer served as a static root file");
assert!(
static_root_file_cache_control("/channels/@me").is_none(),
"an application route was mistaken for a static root file"
);
let response = serve_static_file(
&AppProxyBudgets::default(),
static_dir,
"/robots.txt",
policy,
&HeaderMap::new(),
)
.await;
assert_eq!(response.status(), StatusCode::OK);
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.expect("the crawl-control document was served without a cache policy at all")
.to_str()
.unwrap();
assert_eq!(cache_control, CRAWL_CONTROL_CACHE_CONTROL);
assert_ne!(
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
"a crawl rule change cannot reach a crawler that already fetched a year-long robots.txt"
);
tokio::fs::remove_dir_all(&root).await.unwrap();
}
#[tokio::test]
async fn the_shell_is_never_served_with_the_asset_lifetime() {
let state =
spa_state_serving(ReleaseChannel::Canary, Some(SHELL_WITH_ENDPOINT_HOLES)).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
let cache_control = response
.headers()
.get(header::CACHE_CONTROL)
.expect("the shell was served without a cache policy at all")
.to_str()
.unwrap();
assert_eq!(
cache_control, "no-cache",
"the document naming the hashed bundle must be revalidated on every load"
);
assert_ne!(
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
"a shell cached for a year pins every returning visitor to the deployed-over bundle"
);
}
#[tokio::test]
async fn an_index_upstream_replaces_the_snapshot_with_an_unstorable_busted_document() {
let index_upstream_url = spawn_local_origin(SHELL_WITH_ENDPOINT_HOLES, "text/html").await;
let state = spa_state_reading_its_shell_from(index_upstream_url).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
assert_eq!(
response
.headers()
.get(header::CACHE_CONTROL)
.unwrap()
.to_str()
.unwrap(),
DEV_NO_STORE_CACHE_CONTROL,
"a document fetched from an index upstream was served as cacheable"
);
assert_eq!(
response
.headers()
.get("cdn-cache-control")
.expect("the edge was never told to skip storing this document")
.to_str()
.unwrap(),
"no-store"
);
let served = read_document(response).await;
assert!(
served.contains(r#"src="/assets/app.js?_="#),
"an index upstream served its assets without a cache-busting query"
);
}
#[tokio::test]
async fn the_configured_static_cdn_stands_in_when_discovery_names_none() {
let state =
spa_state_without_discovered_endpoints(Some("https://fallbackcdn.example.test")).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
let served = read_document(response).await;
assert!(
served.contains(
r#"<link rel="preconnect" href="https://fallbackcdn.example.test" crossorigin>"#
),
"the configured static CDN never reached the anonymous preconnect"
);
assert!(
served.contains(r#"<link rel="preconnect" href="https://fallbackcdn.example.test">"#),
"the configured static CDN never reached the credentialed preconnect"
);
assert!(
served.contains(r#"href="https://fallbackcdn.example.test/web/favicon-32x32.png""#),
"the favicon was not resolved against the configured static CDN"
);
assert!(!served.contains("{{STATIC_CDN_ENDPOINT}}"));
assert_eq!(
served.matches("preconnect").count(),
2,
"a media endpoint nobody named still warmed a socket"
);
}
#[tokio::test]
async fn an_endpoint_neither_discovered_nor_configured_warms_no_socket_at_all() {
let state = spa_state_without_discovered_endpoints(None).await;
let response = serve_spa_index(&state, &HeaderMap::new()).await;
assert_eq!(response.status(), StatusCode::OK);
let served = read_document(response).await;
assert_eq!(
served.matches("preconnect").count(),
0,
"an endpoint nobody named still reached the served document"
);
assert!(!served.contains("{{STATIC_CDN_ENDPOINT}}"));
assert!(!served.contains("{{MEDIA_ENDPOINT}}"));
assert!(
served.contains(r#"href="/web/favicon-32x32.png""#),
"an unnamed static CDN left the favicon pointing somewhere other than our own origin"
);
}
#[test]
fn font_mime_types_are_cors_enabled() {
assert!(is_font_mime("font/woff2"));
assert!(is_font_mime("font/woff"));
assert!(is_font_mime("font/ttf"));
assert!(is_font_mime("font/otf"));
assert!(is_font_mime("application/vnd.ms-fontobject"));
assert!(!is_font_mime("text/css; charset=utf-8"));
assert!(!is_font_mime("image/png"));
}
}