fix(selfhost): branding image origins and theme colour (#3389)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-11 13:31:05 +02:00
1 parent cca8227ca6
commit edc913096a
27 files changed
+724 -49

No files matched your search

+398
View File
@@ -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(), &not_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();
}
}
+30
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+12
View File
@@ -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(),
+3
View File
@@ -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(),
+61 -1
View File
@@ -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(),
+3
View File
@@ -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,