mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
fix(selfhost): branding image origins and theme colour (#3389)
This commit is contained in:
1 parent
cca8227ca6
commit
edc913096a
27 files changed
+724
-49
No files matched your search
@@ -0,0 +1,398 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::config::{AppProxyConfig, HttpEndpoint};
|
||||
use axum::http::header;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::watch;
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
const BRANDING_IMAGES: [(&str, &str); 5] = [
|
||||
("icon_url", "FLUXER_APP_ICON_URL"),
|
||||
("symbol_url", "FLUXER_APP_SYMBOL_URL"),
|
||||
("logo_url", "FLUXER_APP_LOGO_URL"),
|
||||
("wordmark_url", "FLUXER_APP_WORDMARK_URL"),
|
||||
("favicon_url", "FLUXER_APP_FAVICON_URL"),
|
||||
];
|
||||
const DISCOVERY_FETCH_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
const DISCOVERY_BODY_BYTES_MAX: usize = 1024 * 1024;
|
||||
const REFRESH_INTERVAL_MIN: Duration = Duration::from_secs(1);
|
||||
const REFRESH_BACKOFF_MAX: Duration = Duration::from_secs(300);
|
||||
|
||||
pub fn origins_from_environment<F>(mut read_var: F) -> Vec<HttpEndpoint>
|
||||
where
|
||||
F: FnMut(&str) -> Option<String>,
|
||||
{
|
||||
distinct_origins(
|
||||
BRANDING_IMAGES
|
||||
.iter()
|
||||
.filter_map(|(_, name)| Some((*name, read_var(name)?))),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn origins_from_discovery(discovery: &serde_json::Value) -> Option<Vec<HttpEndpoint>> {
|
||||
let branding = discovery.pointer("/app_public/branding")?.as_object()?;
|
||||
Some(distinct_origins(BRANDING_IMAGES.iter().filter_map(
|
||||
|(key, _)| Some((*key, branding.get(*key)?.as_str()?.to_owned())),
|
||||
)))
|
||||
}
|
||||
|
||||
fn distinct_origins(urls: impl Iterator<Item = (&'static str, String)>) -> Vec<HttpEndpoint> {
|
||||
let mut origins: Vec<HttpEndpoint> = Vec::new();
|
||||
for (name, url) in urls {
|
||||
if url.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
match HttpEndpoint::origin_of(name, &url) {
|
||||
Ok(origin) => {
|
||||
if !origins.contains(&origin) {
|
||||
origins.push(origin);
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::debug!(%error, "branding image has no origin to allow");
|
||||
}
|
||||
}
|
||||
}
|
||||
origins
|
||||
}
|
||||
|
||||
async fn fetch_discovery(
|
||||
client: &reqwest::Client,
|
||||
upstream_url: &str,
|
||||
) -> anyhow::Result<serde_json::Value> {
|
||||
let mut response = client
|
||||
.get(upstream_url)
|
||||
.header(header::ACCEPT, "application/json")
|
||||
.timeout(DISCOVERY_FETCH_TIMEOUT)
|
||||
.send()
|
||||
.await?;
|
||||
if !response.status().is_success() {
|
||||
anyhow::bail!("upstream discovery returned {}", response.status());
|
||||
}
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
while let Some(chunk) = response.chunk().await? {
|
||||
if body.len() + chunk.len() > DISCOVERY_BODY_BYTES_MAX {
|
||||
anyhow::bail!("upstream discovery exceeds {DISCOVERY_BODY_BYTES_MAX} bytes");
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(serde_json::from_slice(&body)?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BrandingImageOrigins(watch::Sender<Arc<[HttpEndpoint]>>);
|
||||
|
||||
impl BrandingImageOrigins {
|
||||
pub fn new(origins: Vec<HttpEndpoint>) -> Self {
|
||||
Self(watch::Sender::new(origins.into()))
|
||||
}
|
||||
|
||||
pub fn from_config(config: &AppProxyConfig) -> Self {
|
||||
if config.self_hosted {
|
||||
Self::new(config.branding_image_origins.clone())
|
||||
} else {
|
||||
Self::new(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn current(&self) -> Arc<[HttpEndpoint]> {
|
||||
self.0.borrow().clone()
|
||||
}
|
||||
|
||||
pub async fn refresh(
|
||||
&self,
|
||||
client: &reqwest::Client,
|
||||
upstream_url: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
let discovery = fetch_discovery(client, upstream_url).await?;
|
||||
let Some(origins) = origins_from_discovery(&discovery) else {
|
||||
anyhow::bail!("upstream discovery has no app_public.branding object");
|
||||
};
|
||||
let changed = self.0.send_if_modified(|current| {
|
||||
if **current == *origins {
|
||||
return false;
|
||||
}
|
||||
*current = origins.as_slice().into();
|
||||
true
|
||||
});
|
||||
if changed {
|
||||
let origins: Vec<&str> = origins.iter().map(HttpEndpoint::csp_origin).collect();
|
||||
tracing::info!(?origins, "branding image origins updated");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn start_background_refresh(
|
||||
&self,
|
||||
client: reqwest::Client,
|
||||
upstream_url: String,
|
||||
interval: Duration,
|
||||
) -> JoinHandle<()> {
|
||||
self.spawn_refresh_loop(client, upstream_url, interval.max(REFRESH_INTERVAL_MIN))
|
||||
}
|
||||
|
||||
fn spawn_refresh_loop(
|
||||
&self,
|
||||
client: reqwest::Client,
|
||||
upstream_url: String,
|
||||
interval: Duration,
|
||||
) -> JoinHandle<()> {
|
||||
let origins = self.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut consecutive_failures: u32 = 0;
|
||||
loop {
|
||||
let wait = match origins.refresh(&client, &upstream_url).await {
|
||||
Ok(()) => {
|
||||
consecutive_failures = 0;
|
||||
interval
|
||||
}
|
||||
Err(error) => {
|
||||
let backoff = interval
|
||||
.saturating_mul(1 << consecutive_failures.min(5))
|
||||
.min(REFRESH_BACKOFF_MAX);
|
||||
consecutive_failures = consecutive_failures.saturating_add(1);
|
||||
tracing::warn!(
|
||||
%error,
|
||||
url = %upstream_url,
|
||||
consecutive_failures,
|
||||
backoff_ms = backoff.as_millis() as u64,
|
||||
"branding image origin refresh failed, keeping the previous origins"
|
||||
);
|
||||
backoff
|
||||
}
|
||||
};
|
||||
tokio::time::sleep(wait).await;
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use axum::http::StatusCode;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
fn sources(origins: &[HttpEndpoint]) -> Vec<&str> {
|
||||
origins.iter().map(HttpEndpoint::csp_origin).collect()
|
||||
}
|
||||
|
||||
fn endpoint(value: &str) -> HttpEndpoint {
|
||||
HttpEndpoint::parse("TEST_ENDPOINT", value).unwrap()
|
||||
}
|
||||
|
||||
fn fast_client() -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.timeout(Duration::from_secs(2))
|
||||
.build()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
async fn serve_discovery(responses: Vec<(StatusCode, String)>) -> String {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let served = Arc::new(AtomicUsize::new(0));
|
||||
let responses = Arc::new(responses);
|
||||
let router = axum::Router::new().fallback(move || {
|
||||
let index = served.fetch_add(1, Ordering::SeqCst);
|
||||
let responses = Arc::clone(&responses);
|
||||
async move { responses[index.min(responses.len() - 1)].clone() }
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, router).await.unwrap();
|
||||
});
|
||||
format!("http://{addr}/.well-known/fluxer")
|
||||
}
|
||||
|
||||
fn discovery_with_branding(branding: serde_json::Value) -> String {
|
||||
serde_json::json!({"api_code_version": "v1", "app_public": {"branding": branding}})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovery_branding_images_yield_their_distinct_origins() {
|
||||
let discovery = serde_json::json!({
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"product_name": "Example",
|
||||
"icon_url": "https://example.test/logo.png",
|
||||
"symbol_url": " https://example.test/symbol.svg?v=2#dark ",
|
||||
"logo_url": "http://assets.example.test:8443/brand/logo.png",
|
||||
"wordmark_url": null,
|
||||
"favicon_url": "https://EXAMPLE.test:443/favicon.png",
|
||||
"status_page_url": "https://status.example.test"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
sources(&origins_from_discovery(&discovery).unwrap()),
|
||||
["https://example.test", "http://assets.example.test:8443"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branding_images_without_a_network_origin_are_skipped() {
|
||||
let discovery = serde_json::json!({
|
||||
"app_public": {
|
||||
"branding": {
|
||||
"icon_url": "/branding/icon.png",
|
||||
"symbol_url": "data:image/png;base64,AAAA",
|
||||
"logo_url": " ",
|
||||
"wordmark_url": 7,
|
||||
"favicon_url": "ftp://example.test/favicon.png"
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
assert!(origins_from_discovery(&discovery).unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_without_branding_is_not_a_discovery_snapshot() {
|
||||
assert!(origins_from_discovery(&serde_json::json!({"api_code_version": "v1"})).is_none());
|
||||
assert!(origins_from_discovery(&serde_json::json!({"app_public": {}})).is_none());
|
||||
assert!(
|
||||
origins_from_discovery(&serde_json::json!({"app_public": {"branding": null}}))
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
origins_from_discovery(&serde_json::json!({"app_public": {"branding": {}}}))
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_branding_images_yield_their_distinct_origins() {
|
||||
let origins = origins_from_environment(|name| match name {
|
||||
"FLUXER_APP_ICON_URL" => Some("https://example.test/logo.png".to_owned()),
|
||||
"FLUXER_APP_FAVICON_URL" => Some("https://example.test/favicon.png".to_owned()),
|
||||
"FLUXER_APP_WORDMARK_URL" => Some("https://cdn.example.test/wordmark.svg".to_owned()),
|
||||
"FLUXER_APP_LOGO_URL" => Some("not a url".to_owned()),
|
||||
"FLUXER_APP_STATUS_PAGE_URL" => Some("https://status.example.test".to_owned()),
|
||||
_ => None,
|
||||
});
|
||||
|
||||
assert_eq!(
|
||||
sources(&origins),
|
||||
["https://example.test", "https://cdn.example.test"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_self_hosted_instance_seeds_origins_from_its_environment() {
|
||||
let mut config = AppProxyConfig::from_env();
|
||||
config.branding_image_origins = vec![endpoint("https://example.test")];
|
||||
|
||||
config.self_hosted = false;
|
||||
assert!(
|
||||
BrandingImageOrigins::from_config(&config)
|
||||
.current()
|
||||
.is_empty()
|
||||
);
|
||||
|
||||
config.self_hosted = true;
|
||||
assert_eq!(
|
||||
sources(&BrandingImageOrigins::from_config(&config).current()),
|
||||
["https://example.test"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_discovery_snapshot_replaces_the_environment_seed() {
|
||||
let upstream = serve_discovery(vec![(
|
||||
StatusCode::OK,
|
||||
discovery_with_branding(serde_json::json!({
|
||||
"icon_url": "https://dashboard.example.test/icon.png",
|
||||
"favicon_url": null
|
||||
})),
|
||||
)])
|
||||
.await;
|
||||
let origins = BrandingImageOrigins::new(vec![endpoint("https://env.example.test")]);
|
||||
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
|
||||
|
||||
origins.refresh(&fast_client(), &upstream).await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
sources(&origins.current()),
|
||||
["https://dashboard.example.test"]
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_failed_refresh_keeps_the_previous_origins() {
|
||||
let seeded = || BrandingImageOrigins::new(vec![endpoint("https://env.example.test")]);
|
||||
|
||||
let origins = seeded();
|
||||
let unavailable =
|
||||
serve_discovery(vec![(StatusCode::SERVICE_UNAVAILABLE, String::new())]).await;
|
||||
assert!(origins.refresh(&fast_client(), &unavailable).await.is_err());
|
||||
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
|
||||
|
||||
let origins = seeded();
|
||||
let not_json = serve_discovery(vec![(StatusCode::OK, "<html>".to_owned())]).await;
|
||||
assert!(origins.refresh(&fast_client(), ¬_json).await.is_err());
|
||||
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
|
||||
|
||||
let origins = seeded();
|
||||
let no_branding = serve_discovery(vec![(StatusCode::OK, "{}".to_owned())]).await;
|
||||
assert!(origins.refresh(&fast_client(), &no_branding).await.is_err());
|
||||
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
|
||||
|
||||
let origins = seeded();
|
||||
let oversized = serve_discovery(vec![(
|
||||
StatusCode::OK,
|
||||
discovery_with_branding(serde_json::json!({
|
||||
"icon_url": format!(
|
||||
"https://dashboard.example.test/{}",
|
||||
"a".repeat(DISCOVERY_BODY_BYTES_MAX)
|
||||
)
|
||||
})),
|
||||
)])
|
||||
.await;
|
||||
assert!(origins.refresh(&fast_client(), &oversized).await.is_err());
|
||||
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
|
||||
|
||||
let origins = seeded();
|
||||
assert!(
|
||||
origins
|
||||
.refresh(&fast_client(), "http://127.0.0.1:1/.well-known/fluxer")
|
||||
.await
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(sources(&origins.current()), ["https://env.example.test"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_background_refresh_follows_discovery_and_survives_a_failure() {
|
||||
let upstream = serve_discovery(vec![
|
||||
(
|
||||
StatusCode::OK,
|
||||
discovery_with_branding(serde_json::json!({
|
||||
"icon_url": "https://first.example.test/icon.png"
|
||||
})),
|
||||
),
|
||||
(StatusCode::BAD_GATEWAY, String::new()),
|
||||
(
|
||||
StatusCode::OK,
|
||||
discovery_with_branding(serde_json::json!({
|
||||
"icon_url": "https://second.example.test/icon.png"
|
||||
})),
|
||||
),
|
||||
])
|
||||
.await;
|
||||
let origins = BrandingImageOrigins::new(Vec::new());
|
||||
let mut changes = origins.0.subscribe();
|
||||
|
||||
let refresh =
|
||||
origins.spawn_refresh_loop(fast_client(), upstream, Duration::from_millis(10));
|
||||
|
||||
changes.changed().await.unwrap();
|
||||
assert_eq!(sources(&origins.current()), ["https://first.example.test"]);
|
||||
changes.changed().await.unwrap();
|
||||
assert_eq!(sources(&origins.current()), ["https://second.example.test"]);
|
||||
refresh.abort();
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,8 @@ use reqwest::Url;
|
||||
use std::env;
|
||||
use std::fmt;
|
||||
|
||||
const DEFAULT_DISCOVERY_UPSTREAM_URL: &str = "http://localhost:8088/api/.well-known/fluxer";
|
||||
|
||||
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||
pub enum InvalidAppProxyEnvironmentError {
|
||||
InvalidValue {
|
||||
@@ -104,6 +106,19 @@ impl HttpEndpoint {
|
||||
Ok(Self { url, csp_origin })
|
||||
}
|
||||
|
||||
pub fn origin_of(
|
||||
name: &'static str,
|
||||
value: &str,
|
||||
) -> Result<Self, InvalidAppProxyEnvironmentError> {
|
||||
let url = Url::parse(value.trim())
|
||||
.ok()
|
||||
.filter(|url| matches!(url.scheme(), "http" | "https") && url.host_str().is_some())
|
||||
.ok_or_else(|| {
|
||||
InvalidAppProxyEnvironmentError::new(name, value, "an absolute HTTP or HTTPS URL")
|
||||
})?;
|
||||
Self::parse(name, &url.origin().ascii_serialization())
|
||||
}
|
||||
|
||||
pub fn with_host_prefix(
|
||||
&self,
|
||||
name: &'static str,
|
||||
@@ -344,6 +359,9 @@ pub struct AppProxyConfig {
|
||||
pub media_endpoint: Option<HttpEndpoint>,
|
||||
pub s3_public_endpoint: Option<HttpEndpoint>,
|
||||
pub s3_uploads_endpoint: Option<HttpEndpoint>,
|
||||
pub branding_image_origins: Vec<HttpEndpoint>,
|
||||
pub discovery_upstream_url: String,
|
||||
pub discovery_refresh_interval_ms: u64,
|
||||
pub release_channel: ReleaseChannel,
|
||||
pub build_version: String,
|
||||
pub csp: CspConfig,
|
||||
@@ -467,6 +485,18 @@ impl AppProxyConfig {
|
||||
),
|
||||
s3_public_endpoint,
|
||||
s3_uploads_endpoint,
|
||||
branding_image_origins: crate::branding_images::origins_from_environment(
|
||||
cfg::env_value,
|
||||
),
|
||||
discovery_upstream_url: cfg::read_env(
|
||||
"DISCOVERY_UPSTREAM_URL",
|
||||
DEFAULT_DISCOVERY_UPSTREAM_URL,
|
||||
),
|
||||
discovery_refresh_interval_ms: parse_env_or_warn(
|
||||
"DISCOVERY_REFRESH_INTERVAL_MS",
|
||||
&cfg::read_env("DISCOVERY_REFRESH_INTERVAL_MS", "60000"),
|
||||
60_000u64,
|
||||
),
|
||||
release_channel,
|
||||
build_version: cfg::read_first_env(
|
||||
&["BUILD_VERSION", "FLUXER_BUILD_VERSION"],
|
||||
|
||||
+95
-15
@@ -205,6 +205,7 @@ impl CompiledCspPolicy {
|
||||
&config,
|
||||
&[InlineScriptHash::of("")],
|
||||
configured_sources,
|
||||
&[],
|
||||
))
|
||||
.map_err(CspCompileError::InvalidSpaPolicy)?;
|
||||
Ok(Self {
|
||||
@@ -218,11 +219,16 @@ impl CompiledCspPolicy {
|
||||
self.asset.clone()
|
||||
}
|
||||
|
||||
pub fn spa_headers(&self, script_hashes: &[InlineScriptHash]) -> CspHeaderValues {
|
||||
pub fn spa_headers(
|
||||
&self,
|
||||
script_hashes: &[InlineScriptHash],
|
||||
branding_image_origins: &[HttpEndpoint],
|
||||
) -> CspHeaderValues {
|
||||
let enforced = HeaderValue::from_str(&build_csp(
|
||||
&self.config,
|
||||
script_hashes,
|
||||
&self.runtime_sources,
|
||||
branding_image_origins,
|
||||
))
|
||||
.expect(
|
||||
"every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \
|
||||
@@ -239,18 +245,26 @@ fn build_csp(
|
||||
config: &CspConfig,
|
||||
script_hashes: &[InlineScriptHash],
|
||||
runtime_sources: &RuntimeCspSources,
|
||||
branding_image_origins: &[HttpEndpoint],
|
||||
) -> String {
|
||||
build_csp_directives(config, Some(script_hashes), runtime_sources).join("; ")
|
||||
build_csp_directives(
|
||||
config,
|
||||
Some(script_hashes),
|
||||
runtime_sources,
|
||||
branding_image_origins,
|
||||
)
|
||||
.join("; ")
|
||||
}
|
||||
|
||||
fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String {
|
||||
build_csp_directives(config, None, runtime_sources).join("; ")
|
||||
build_csp_directives(config, None, runtime_sources, &[]).join("; ")
|
||||
}
|
||||
|
||||
fn build_csp_directives(
|
||||
config: &CspConfig,
|
||||
script_hashes: Option<&[InlineScriptHash]>,
|
||||
runtime_sources: &RuntimeCspSources,
|
||||
branding_image_origins: &[HttpEndpoint],
|
||||
) -> Vec<String> {
|
||||
let mut directives = Vec::with_capacity(14);
|
||||
|
||||
@@ -278,6 +292,9 @@ fn build_csp_directives(
|
||||
let mut img = vec!["'self'".to_owned(), "blob:".to_owned(), "data:".to_owned()];
|
||||
extend_from(&mut img, &config.extra_img_src, IMAGE_SOURCES);
|
||||
extend_runtime_sources(&mut img, runtime_sources, true, true);
|
||||
for origin in branding_image_origins {
|
||||
push_endpoint_source(&mut img, Some(origin));
|
||||
}
|
||||
directives.push(format!("img-src {}", img.join(" ")));
|
||||
|
||||
let mut media = vec!["'self'".to_owned(), "blob:".to_owned()];
|
||||
@@ -431,7 +448,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_csp_includes_required_directives() {
|
||||
let config = default_csp_config();
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]);
|
||||
assert!(csp.contains("default-src"));
|
||||
assert!(csp.contains("script-src"));
|
||||
assert!(csp.contains("style-src"));
|
||||
@@ -450,7 +467,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_csp_allows_the_apex_domain_for_images_and_media() {
|
||||
let config = default_csp_config();
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]);
|
||||
for name in ["img-src ", "media-src "] {
|
||||
let directive = csp
|
||||
.split("; ")
|
||||
@@ -468,7 +485,7 @@ mod tests {
|
||||
#[test]
|
||||
fn build_csp_allows_blob_connections_for_camera_background_media() {
|
||||
let config = default_csp_config();
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]);
|
||||
let connect = csp
|
||||
.split("; ")
|
||||
.find(|directive| directive.starts_with("connect-src "))
|
||||
@@ -502,6 +519,7 @@ mod tests {
|
||||
&config,
|
||||
&[hash_of("first()"), hash_of("second()")],
|
||||
&runtime_sources(),
|
||||
&[],
|
||||
);
|
||||
let script = csp
|
||||
.split("; ")
|
||||
@@ -529,6 +547,7 @@ mod tests {
|
||||
&default_csp_config(),
|
||||
&[hash_of("boot()")],
|
||||
&runtime_sources(),
|
||||
&[],
|
||||
);
|
||||
assert!(!csp.contains("hcaptcha"));
|
||||
assert!(!csp.contains("challenges.cloudflare.com"));
|
||||
@@ -537,7 +556,7 @@ mod tests {
|
||||
#[test]
|
||||
fn csp_no_double_spaces_or_trailing_semicolons() {
|
||||
let config = default_csp_config();
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]);
|
||||
assert!(!csp.contains(" "), "CSP contains double spaces");
|
||||
assert!(!csp.ends_with(';'), "CSP ends with semicolon");
|
||||
assert!(!csp.ends_with("; "), "CSP ends with semicolon+space");
|
||||
@@ -555,14 +574,14 @@ mod tests {
|
||||
),
|
||||
..Default::default()
|
||||
};
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]);
|
||||
assert!(csp.contains("report-uri https://example.com/csp-report"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_csp_excludes_report_uri_when_none() {
|
||||
let config = default_csp_config();
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources(), &[]);
|
||||
assert!(!csp.contains("report-uri"));
|
||||
}
|
||||
|
||||
@@ -574,7 +593,7 @@ mod tests {
|
||||
media_endpoint: Some(endpoint("https://media.example.test")),
|
||||
..Default::default()
|
||||
};
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources);
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources, &[]);
|
||||
assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
|
||||
assert!(csp.contains("https://static.example.test"));
|
||||
assert!(csp.contains("https://media.example.test"));
|
||||
@@ -616,7 +635,7 @@ mod tests {
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources);
|
||||
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources, &[]);
|
||||
|
||||
assert!(csp.contains("http://localhost:3900"));
|
||||
assert!(csp.contains("http://fluxer-uploads.localhost:3900"));
|
||||
@@ -654,7 +673,7 @@ mod tests {
|
||||
};
|
||||
let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap();
|
||||
|
||||
let headers = policy.spa_headers(&[hash_of("boot()")]);
|
||||
let headers = policy.spa_headers(&[hash_of("boot()")], &[]);
|
||||
let header = headers.enforced.to_str().unwrap();
|
||||
|
||||
assert!(header.contains(hash_of("boot()").as_source()));
|
||||
@@ -670,7 +689,7 @@ mod tests {
|
||||
config.media_endpoint = Some(endpoint("https://media.configured.test/"));
|
||||
let policy = CompiledCspPolicy::from_config(&config).unwrap();
|
||||
|
||||
let headers = policy.spa_headers(&[hash_of("boot()")]);
|
||||
let headers = policy.spa_headers(&[hash_of("boot()")], &[]);
|
||||
let header = headers.enforced.to_str().unwrap();
|
||||
for directive in [
|
||||
"img-src",
|
||||
@@ -721,8 +740,69 @@ mod tests {
|
||||
);
|
||||
let hashes = [hash_of("boot()")];
|
||||
assert_eq!(
|
||||
policy.spa_headers(&hashes).enforced.to_str().unwrap(),
|
||||
build_csp(&config, &hashes, &sources)
|
||||
policy.spa_headers(&hashes, &[]).enforced.to_str().unwrap(),
|
||||
build_csp(&config, &hashes, &sources, &[])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branding_image_origins_are_granted_to_images_and_nothing_else() {
|
||||
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
|
||||
let branding = [
|
||||
endpoint("https://example.test"),
|
||||
endpoint("http://assets.example.test:8443"),
|
||||
];
|
||||
|
||||
let headers = policy.spa_headers(&[hash_of("boot()")], &branding);
|
||||
let header = headers.enforced.to_str().unwrap();
|
||||
|
||||
for directive in header.split("; ") {
|
||||
let grants_branding = directive
|
||||
.split(' ')
|
||||
.any(|source| branding.iter().any(|origin| origin.csp_origin() == source));
|
||||
assert_eq!(
|
||||
grants_branding,
|
||||
directive.starts_with("img-src "),
|
||||
"unexpected branding grant in: {directive}"
|
||||
);
|
||||
}
|
||||
let img = header
|
||||
.split("; ")
|
||||
.find(|directive| directive.starts_with("img-src "))
|
||||
.unwrap();
|
||||
assert!(img.ends_with(" https://example.test http://assets.example.test:8443"));
|
||||
assert!(
|
||||
!policy
|
||||
.asset_header()
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.contains("example.test")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_branding_image_origin_the_policy_already_grants_is_not_repeated() {
|
||||
let configured = RuntimeCspSources {
|
||||
media_endpoint: Some(endpoint("https://media.example.test/media")),
|
||||
..Default::default()
|
||||
};
|
||||
let policy = CompiledCspPolicy::compile(default_csp_config(), &configured).unwrap();
|
||||
|
||||
let headers = policy.spa_headers(
|
||||
&[hash_of("boot()")],
|
||||
&[endpoint("https://media.example.test")],
|
||||
);
|
||||
let header = headers.enforced.to_str().unwrap();
|
||||
let img = header
|
||||
.split("; ")
|
||||
.find(|directive| directive.starts_with("img-src "))
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
img.split(' ')
|
||||
.filter(|source| *source == "https://media.example.test")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
pub mod branding_images;
|
||||
pub mod config;
|
||||
pub mod csp;
|
||||
mod local_files;
|
||||
|
||||
@@ -8,6 +8,7 @@ use fluxer_app_proxy::{
|
||||
state::{AppState, build_http_client},
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
use tokio::{net::TcpListener, runtime::Builder};
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
@@ -37,6 +38,14 @@ fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
.context("failed to load the Fluxer app proxy static directory")?;
|
||||
|
||||
let branding_refresh = state.config.self_hosted.then(|| {
|
||||
state.branding_image_origins.start_background_refresh(
|
||||
state.http_client.clone(),
|
||||
state.config.discovery_upstream_url.clone(),
|
||||
Duration::from_millis(state.config.discovery_refresh_interval_ms),
|
||||
)
|
||||
});
|
||||
|
||||
let router = build_router(state);
|
||||
let listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
@@ -48,6 +57,9 @@ fn main() -> anyhow::Result<()> {
|
||||
.await
|
||||
.context("app proxy server exited unexpectedly")?;
|
||||
|
||||
if let Some(branding_refresh) = branding_refresh {
|
||||
branding_refresh.abort();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -235,6 +235,9 @@ mod tests {
|
||||
LocalFileStore::load_blocking(std::path::Path::new(&config.static_dir), &budgets)
|
||||
.expect("the test static directory must exist");
|
||||
AppState {
|
||||
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config(
|
||||
&config,
|
||||
),
|
||||
config: Arc::new(config),
|
||||
csp,
|
||||
http_client: build_http_client().unwrap(),
|
||||
|
||||
@@ -28,6 +28,9 @@ mod tests {
|
||||
crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets)
|
||||
.expect("the test static directory must exist");
|
||||
AppState {
|
||||
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config(
|
||||
&config,
|
||||
),
|
||||
config: Arc::new(config),
|
||||
csp,
|
||||
http_client: build_http_client().unwrap(),
|
||||
|
||||
@@ -204,7 +204,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap, request_path: &s
|
||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||
}
|
||||
};
|
||||
let csp = state.csp.spa_headers(&inline_script_hashes(&html));
|
||||
let csp = state.csp.spa_headers(
|
||||
&inline_script_hashes(&html),
|
||||
&state.branding_image_origins.current(),
|
||||
);
|
||||
build_spa_response(html.into_boxed_str(), &csp, should_bust_dev_assets)
|
||||
}
|
||||
|
||||
@@ -889,6 +892,60 @@ mod tests {
|
||||
assert!(served.contains(r#"<meta name="viewport""#));
|
||||
}
|
||||
|
||||
fn image_sources_of(policy: &str) -> Vec<String> {
|
||||
policy
|
||||
.split("; ")
|
||||
.find(|directive| directive.starts_with("img-src "))
|
||||
.expect("the content security policy has no img-src directive")
|
||||
.split(' ')
|
||||
.skip(1)
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn branding_origin() -> HttpEndpoint {
|
||||
HttpEndpoint::origin_of("TEST_BRANDING_IMAGE", "https://example.test/logo.png")
|
||||
.expect("the test branding image must have an origin")
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_self_hosted_instance_allows_its_branding_image_origins() {
|
||||
let mut state = assemble_spa_state(SpaIndexSource::bundled(SHIPPED_APP_SHELL), None, true);
|
||||
let plain = serve_spa_index(&state, &HeaderMap::new(), "/login").await;
|
||||
let plain_images = image_sources_of(&policy_of(&plain));
|
||||
let plain_document = read_document(plain).await;
|
||||
assert!(!plain_images.contains(&"https://example.test".to_owned()));
|
||||
|
||||
state.branding_image_origins =
|
||||
crate::branding_images::BrandingImageOrigins::new(vec![branding_origin()]);
|
||||
let branded = serve_spa_index(&state, &HeaderMap::new(), "/login").await;
|
||||
let branded_policy = policy_of(&branded);
|
||||
let branded_document = read_document(branded).await;
|
||||
|
||||
let mut expected_images = plain_images;
|
||||
expected_images.push("https://example.test".to_owned());
|
||||
assert_eq!(image_sources_of(&branded_policy), expected_images);
|
||||
assert_eq!(branded_policy.matches("https://example.test").count(), 1);
|
||||
assert_eq!(branded_document, plain_document);
|
||||
assert!(!branded_document.contains("example.test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_official_instance_allows_no_branding_image_origins() {
|
||||
let mut config = AppProxyConfig::from_env();
|
||||
config.self_hosted = false;
|
||||
config.branding_image_origins = vec![branding_origin()];
|
||||
let mut state = spa_state_serving(SHIPPED_APP_SHELL);
|
||||
state.branding_image_origins =
|
||||
crate::branding_images::BrandingImageOrigins::from_config(&config);
|
||||
|
||||
let response = serve_spa_index(&state, &HeaderMap::new(), "/login").await;
|
||||
|
||||
assert!(
|
||||
!image_sources_of(&policy_of(&response)).contains(&"https://example.test".to_owned())
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_official_instance_keeps_its_link_preview_metadata() {
|
||||
let state = spa_state_serving(SHIPPED_APP_SHELL);
|
||||
@@ -1080,6 +1137,9 @@ mod tests {
|
||||
crate::local_files::LocalFileStore::load_blocking(std::path::Path::new("."), &budgets)
|
||||
.expect("the test static directory must exist");
|
||||
AppState {
|
||||
branding_image_origins: crate::branding_images::BrandingImageOrigins::from_config(
|
||||
&config,
|
||||
),
|
||||
config: Arc::new(config),
|
||||
csp,
|
||||
http_client: reqwest::Client::new(),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
use crate::branding_images::BrandingImageOrigins;
|
||||
use crate::config::{AppProxyConfig, HttpUrl};
|
||||
use crate::csp::CompiledCspPolicy;
|
||||
use crate::local_files::LocalFileStore;
|
||||
@@ -82,6 +83,7 @@ impl SpaIndexSource {
|
||||
pub struct AppState {
|
||||
pub config: Arc<AppProxyConfig>,
|
||||
pub csp: Arc<CompiledCspPolicy>,
|
||||
pub branding_image_origins: BrandingImageOrigins,
|
||||
pub http_client: reqwest::Client,
|
||||
pub spa_index_source: SpaIndexSource,
|
||||
pub local_asset_prefixes: Option<Arc<[&'static str]>>,
|
||||
@@ -102,6 +104,7 @@ impl AppState {
|
||||
let local_asset_prefixes = (!spa_index_source.is_upstream())
|
||||
.then(|| present_local_asset_prefixes(&config.static_dir));
|
||||
Ok(Self {
|
||||
branding_image_origins: BrandingImageOrigins::from_config(&config),
|
||||
config,
|
||||
csp,
|
||||
http_client,
|
||||
|
||||
Reference in new issue
Block a user