mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(app-proxy): make the SPA shell identical for every visitor (#3112)
This commit is contained in:
Generated
+1
@@ -2040,6 +2040,7 @@ dependencies = [
|
|||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
"tower",
|
"tower",
|
||||||
|
|||||||
@@ -23,9 +23,9 @@
|
|||||||
<meta name="display" content="standalone">
|
<meta name="display" content="standalone">
|
||||||
<link rel="license" href="/assets/fonts-NOTICE.txt">
|
<link rel="license" href="/assets/fonts-NOTICE.txt">
|
||||||
<!--{{FLUXER_BOOTSTRAP}}-->
|
<!--{{FLUXER_BOOTSTRAP}}-->
|
||||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
<script>(function(){try{var loc=window.location;if(loc.pathname==='/'){var target='/channels/@me';if(loc.search)target+=loc.search;if(loc.hash)target+=loc.hash;loc.replace(target);}}catch(e){}})();</script>
|
||||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
<script>(function(){try{var t=localStorage.getItem('theme');if(t){document.documentElement.classList.add('theme-'+t)}}catch{}})()</script>
|
||||||
<script nonce="{{CSP_NONCE_PLACEHOLDER}}">(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
<script>(function(){try{if(typeof WebSocket!=='function')return;if(window.location.pathname.indexOf('/migrate/')===0)return;var t=localStorage.getItem('token');if(!t||t==='undefined'||t==='null')return;var b=window.__FLUXER_BOOTSTRAP__;var g=b&&b.instance&&b.instance.endpoints&&b.instance.endpoints.gateway;if(!g)return;var u=new URL(g);u.searchParams.set('v','1');u.searchParams.set('encoding','json');u.searchParams.set('compress','zstd-stream');u.searchParams.set('stream','1');var url=u.toString();var ws=new WebSocket(url);ws.binaryType='arraybuffer';var s={open:false,url:url,messages:[],startedAt:Date.now()};ws.onopen=function(){s.open=true};ws.onmessage=function(e){s.messages.push(e)};ws.onclose=ws.onerror=function(){window.__FLUXER_FAST_CONNECT__=null};window.__FLUXER_FAST_CONNECT__={ws:ws,state:s};setTimeout(function(){var h=window.__FLUXER_FAST_CONNECT__;if(h&&h.ws===ws){window.__FLUXER_FAST_CONNECT__=null;try{ws.close(1000,'Fast connect unclaimed')}catch(e){}}},30000)}catch(e){}})()</script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ async function bootstrapApp(): Promise<void> {
|
|||||||
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
loadLazyModule(() => import('@app/features/auth/state/AccountManager')),
|
||||||
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
|
loadLazyModule(() => import('@app/features/channel/state/ChannelDisplayName')),
|
||||||
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
|
loadLazyModule(() => import('@app/features/channel/state/ChannelFrecency')),
|
||||||
loadLazyModule(() => import('@app/features/app/state/GeoIP')),
|
loadLazyModule(() => import('@app/features/app/state/GeoIP')).then(({default: GeoIP}) => GeoIP.load()),
|
||||||
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
|
loadLazyModule(() => import('@app/features/input/state/InputKeybind')),
|
||||||
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
|
loadLazyModule(() => import('@app/features/auth/state/NewDeviceMonitoring')),
|
||||||
loadLazyModule(() => import('@app/features/ui/state/Notification')),
|
loadLazyModule(() => import('@app/features/ui/state/Notification')),
|
||||||
|
|||||||
Vendored
+1
-5
@@ -2,10 +2,7 @@
|
|||||||
|
|
||||||
import MediaEngineFacade from '@app/features/voice/engine/MediaEngineFacade';
|
import MediaEngineFacade from '@app/features/voice/engine/MediaEngineFacade';
|
||||||
import type {ElectronAPI} from '@app/features/platform/types/Electron';
|
import type {ElectronAPI} from '@app/features/platform/types/Electron';
|
||||||
import type {
|
import type {InstanceDiscoveryResponse} from '@fluxer/instance_bootstrap/src/Types';
|
||||||
GeolocationResponse,
|
|
||||||
InstanceDiscoveryResponse,
|
|
||||||
} from '@fluxer/instance_bootstrap/src/Types';
|
|
||||||
import {Buffer} from 'buffer';
|
import {Buffer} from 'buffer';
|
||||||
|
|
||||||
type MediaEngineInstance = typeof MediaEngineFacade;
|
type MediaEngineInstance = typeof MediaEngineFacade;
|
||||||
@@ -31,7 +28,6 @@ interface FluxerBootstrapGlobal {
|
|||||||
bootstrapApiPublicEndpoint?: string;
|
bootstrapApiPublicEndpoint?: string;
|
||||||
};
|
};
|
||||||
instance: InstanceDiscoveryResponse;
|
instance: InstanceDiscoveryResponse;
|
||||||
geoip: GeolocationResponse;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
|
|||||||
@@ -1,40 +1,97 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
import type {GeoEntry, GeolocationResponse} from '@fluxer/instance_bootstrap/src/Types';
|
import {Logger} from '@app/features/platform/utils/AppLogger';
|
||||||
|
import ageGeos from '@fluxer/constants/src/AgeGeos.json';
|
||||||
|
import type {GeoEntry} from '@fluxer/instance_bootstrap/src/Types';
|
||||||
|
import {GeolocationResponse} from '@fluxer/schema/src/domains/geolocation/GeolocationSchemas';
|
||||||
import {makeAutoObservable, runInAction} from 'mobx';
|
import {makeAutoObservable, runInAction} from 'mobx';
|
||||||
|
|
||||||
|
const GEOIP_PATH = '/_geoip';
|
||||||
|
const GEOIP_ATTEMPTS = 3;
|
||||||
|
const GEOIP_ATTEMPT_TIMEOUT_MS = 2000;
|
||||||
|
const GEOIP_RETRY_DELAY_MS = 200;
|
||||||
|
|
||||||
|
const logger = new Logger('GeoIP');
|
||||||
|
|
||||||
interface ConnectionGeoCoordinates {
|
interface ConnectionGeoCoordinates {
|
||||||
latitude: string | null;
|
latitude: string | null;
|
||||||
longitude: string | null;
|
longitude: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getInlinedGeoip(): GeolocationResponse {
|
const UNRESOLVED_GEOIP: GeolocationResponse = {
|
||||||
const bootstrap = typeof window !== 'undefined' ? window.__FLUXER_BOOTSTRAP__ : undefined;
|
countryCode: null,
|
||||||
if (!bootstrap) {
|
regionCode: null,
|
||||||
throw new Error('window.__FLUXER_BOOTSTRAP__ is missing — app must be served by fluxer_app_proxy');
|
latitude: null,
|
||||||
}
|
longitude: null,
|
||||||
return bootstrap.geoip;
|
ageRestrictedGeos: ageGeos.ageRestrictedGeos,
|
||||||
|
ageBlockedGeos: ageGeos.ageBlockedGeos,
|
||||||
|
};
|
||||||
|
|
||||||
|
function wait(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchGeoipOnce(): Promise<GeolocationResponse> {
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => controller.abort(), GEOIP_ATTEMPT_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
const response = await fetch(GEOIP_PATH, {
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'omit',
|
||||||
|
headers: {Accept: 'application/json'},
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`GeoIP lookup failed with status ${response.status}`);
|
||||||
|
}
|
||||||
|
return GeolocationResponse.parse(await response.json());
|
||||||
|
} finally {
|
||||||
|
clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGeoip(): Promise<GeolocationResponse> {
|
||||||
|
for (let attempt = 1; ; attempt++) {
|
||||||
|
try {
|
||||||
|
return await fetchGeoipOnce();
|
||||||
|
} catch (error) {
|
||||||
|
if (attempt >= GEOIP_ATTEMPTS) {
|
||||||
|
logger.warn('GeoIP lookup failed, continuing without a location:', error);
|
||||||
|
return UNRESOLVED_GEOIP;
|
||||||
|
}
|
||||||
|
await wait(GEOIP_RETRY_DELAY_MS * attempt);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let loading: Promise<void> | null = null;
|
||||||
|
|
||||||
class GeoIP {
|
class GeoIP {
|
||||||
countryCode: string | null;
|
countryCode: string | null = UNRESOLVED_GEOIP.countryCode;
|
||||||
regionCode: string | null;
|
regionCode: string | null = UNRESOLVED_GEOIP.regionCode;
|
||||||
latitude: string | null;
|
latitude: string | null = UNRESOLVED_GEOIP.latitude;
|
||||||
longitude: string | null;
|
longitude: string | null = UNRESOLVED_GEOIP.longitude;
|
||||||
ageRestrictedGeos: ReadonlyArray<GeoEntry>;
|
ageRestrictedGeos: ReadonlyArray<GeoEntry> = UNRESOLVED_GEOIP.ageRestrictedGeos;
|
||||||
ageBlockedGeos: ReadonlyArray<GeoEntry>;
|
ageBlockedGeos: ReadonlyArray<GeoEntry> = UNRESOLVED_GEOIP.ageBlockedGeos;
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const data = getInlinedGeoip();
|
|
||||||
this.countryCode = data.countryCode;
|
|
||||||
this.regionCode = data.regionCode;
|
|
||||||
this.latitude = data.latitude;
|
|
||||||
this.longitude = data.longitude;
|
|
||||||
this.ageRestrictedGeos = data.ageRestrictedGeos;
|
|
||||||
this.ageBlockedGeos = data.ageBlockedGeos;
|
|
||||||
makeAutoObservable(this, {}, {autoBind: true});
|
makeAutoObservable(this, {}, {autoBind: true});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
load(): Promise<void> {
|
||||||
|
loading ??= fetchGeoip().then((data) => {
|
||||||
|
runInAction(() => {
|
||||||
|
this.countryCode = data.countryCode;
|
||||||
|
this.regionCode = data.regionCode;
|
||||||
|
this.latitude = data.latitude;
|
||||||
|
this.longitude = data.longitude;
|
||||||
|
this.ageRestrictedGeos = data.ageRestrictedGeos;
|
||||||
|
this.ageBlockedGeos = data.ageBlockedGeos;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return loading;
|
||||||
|
}
|
||||||
|
|
||||||
applyConnectionFallbackCoordinates(data: ConnectionGeoCoordinates): void {
|
applyConnectionFallbackCoordinates(data: ConnectionGeoCoordinates): void {
|
||||||
if (data.latitude === null || data.longitude === null) {
|
if (data.latitude === null || data.longitude === null) {
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ const UNRENDERED_INDEX_TEMPLATE = [
|
|||||||
'<!doctype html><html lang="en"><head>',
|
'<!doctype html><html lang="en"><head>',
|
||||||
'<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">',
|
'<link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">',
|
||||||
'<link rel="apple-touch-icon" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">',
|
'<link rel="apple-touch-icon" href="{{STATIC_CDN_ENDPOINT}}/web/apple-touch-icon.png">',
|
||||||
'<script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script>',
|
'<script></script>',
|
||||||
'</head><body><div id="root"></div></body></html>',
|
'</head><body><div id="root"></div></body></html>',
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
const RENDERED_INDEX_DOCUMENT = [
|
const RENDERED_INDEX_DOCUMENT = [
|
||||||
'<!doctype html><html lang="en"><head>',
|
'<!doctype html><html lang="en"><head>',
|
||||||
'<link rel="preconnect" href="https://cdn.fluxer.test">',
|
'<link rel="preconnect" href="https://cdn.fluxer.test">',
|
||||||
'<script nonce="abc123">window.__FLUXER_BOOTSTRAP__={"instance":{}};</script>',
|
'<script>window.__FLUXER_BOOTSTRAP__={"instance":{}};</script>',
|
||||||
'</head><body><div id="root"></div></body></html>',
|
'</head><body><div id="root"></div></body></html>',
|
||||||
].join('');
|
].join('');
|
||||||
|
|
||||||
|
|||||||
@@ -344,14 +344,6 @@ const BOOTSTRAP_ENDPOINT = 'https://primary.test/api';
|
|||||||
registration: {collect_date_of_birth: true},
|
registration: {collect_date_of_birth: true},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
geoip: {
|
|
||||||
countryCode: null,
|
|
||||||
regionCode: null,
|
|
||||||
latitude: null,
|
|
||||||
longitude: null,
|
|
||||||
ageRestrictedGeos: [],
|
|
||||||
ageBlockedGeos: [],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const {VoiceEngineV2AppScreenShareExecutionAdapter, shouldRestoreScreenShareAfterReconnect} = await import(
|
const {VoiceEngineV2AppScreenShareExecutionAdapter, shouldRestoreScreenShareAfterReconnect} = await import(
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ rand = "0.10"
|
|||||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "gzip", "brotli", "deflate"] }
|
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls", "stream", "gzip", "brotli", "deflate"] }
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
serde_json = "1.0.151"
|
serde_json = "1.0.151"
|
||||||
|
sha2 = "0.11.0"
|
||||||
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "time", "fs"] }
|
tokio = { version = "1.53.1", features = ["macros", "net", "rt-multi-thread", "signal", "time", "fs"] }
|
||||||
tokio-util = { version = "0.7.19", features = ["io"] }
|
tokio-util = { version = "0.7.19", features = ["io"] }
|
||||||
tower = { version = "0.5.3", features = ["util"] }
|
tower = { version = "0.5.3", features = ["util"] }
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ use serde::Serialize;
|
|||||||
pub struct BootstrapPayload<'a> {
|
pub struct BootstrapPayload<'a> {
|
||||||
pub config: BootstrapConfig<'a>,
|
pub config: BootstrapConfig<'a>,
|
||||||
pub instance: &'a serde_json::Value,
|
pub instance: &'a serde_json::Value,
|
||||||
pub geoip: &'a serde_json::Value,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
@@ -38,12 +37,7 @@ struct LegacyConfig<'a> {
|
|||||||
bootstrap_api_public_endpoint: Option<&'a str>,
|
bootstrap_api_public_endpoint: Option<&'a str>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn build_bootstrap_script(
|
pub fn build_bootstrap_script(config: &AppProxyConfig, discovery: &DiscoveryResponse) -> String {
|
||||||
config: &AppProxyConfig,
|
|
||||||
discovery: &DiscoveryResponse,
|
|
||||||
geoip: &serde_json::Value,
|
|
||||||
nonce: &str,
|
|
||||||
) -> String {
|
|
||||||
let api_public_endpoint =
|
let api_public_endpoint =
|
||||||
api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery);
|
api_public_endpoint(config.bootstrap_api_public_endpoint.as_deref(), discovery);
|
||||||
|
|
||||||
@@ -54,7 +48,6 @@ pub fn build_bootstrap_script(
|
|||||||
bootstrap_api_public_endpoint: api_public_endpoint,
|
bootstrap_api_public_endpoint: api_public_endpoint,
|
||||||
},
|
},
|
||||||
instance: &discovery.data,
|
instance: &discovery.data,
|
||||||
geoip,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let legacy = LegacyConfig {
|
let legacy = LegacyConfig {
|
||||||
@@ -67,7 +60,7 @@ pub fn build_bootstrap_script(
|
|||||||
let legacy_json = escape_json_for_script(&serde_json::to_string(&legacy).unwrap());
|
let legacy_json = escape_json_for_script(&serde_json::to_string(&legacy).unwrap());
|
||||||
|
|
||||||
format!(
|
format!(
|
||||||
r#"<script nonce="{nonce}">window.__FLUXER_BOOTSTRAP__={bootstrap_json};window.__FLUXER_CONFIG__={legacy_json};</script>"#
|
r#"<script>window.__FLUXER_BOOTSTRAP__={bootstrap_json};window.__FLUXER_CONFIG__={legacy_json};</script>"#
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,7 +137,6 @@ const STATIC_PRECONNECT_TAGS: [&str; 2] = [
|
|||||||
|
|
||||||
pub fn inject_bootstrap(
|
pub fn inject_bootstrap(
|
||||||
html: &str,
|
html: &str,
|
||||||
nonce: &str,
|
|
||||||
script_tag: &str,
|
script_tag: &str,
|
||||||
static_cdn_endpoint: &str,
|
static_cdn_endpoint: &str,
|
||||||
media_endpoint: &str,
|
media_endpoint: &str,
|
||||||
@@ -152,35 +144,34 @@ pub fn inject_bootstrap(
|
|||||||
let static_cdn = static_cdn_endpoint.trim_end_matches('/');
|
let static_cdn = static_cdn_endpoint.trim_end_matches('/');
|
||||||
let media = media_endpoint.trim_end_matches('/');
|
let media = media_endpoint.trim_end_matches('/');
|
||||||
|
|
||||||
let nonced = html.replace("{{CSP_NONCE_PLACEHOLDER}}", nonce);
|
let html = apply_static_preconnect(html.to_owned(), static_cdn);
|
||||||
let nonced = apply_static_preconnect(nonced, static_cdn);
|
let html = html.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
|
||||||
let nonced = nonced.replace("{{STATIC_CDN_ENDPOINT}}", static_cdn);
|
let html = apply_media_preconnect(&html, media, static_cdn);
|
||||||
let nonced = apply_media_preconnect(&nonced, media, static_cdn);
|
|
||||||
|
|
||||||
if nonced.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
|
if html.contains("<!--{{FLUXER_BOOTSTRAP}}-->") {
|
||||||
return nonced.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
|
return html.replace("<!--{{FLUXER_BOOTSTRAP}}-->", script_tag);
|
||||||
}
|
}
|
||||||
if nonced.contains("{{FLUXER_BOOTSTRAP}}") {
|
if html.contains("{{FLUXER_BOOTSTRAP}}") {
|
||||||
return nonced.replace("{{FLUXER_BOOTSTRAP}}", script_tag);
|
return html.replace("{{FLUXER_BOOTSTRAP}}", script_tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
let insert_at = nonced
|
let insert_at = html
|
||||||
.find("<head>")
|
.find("<head>")
|
||||||
.map(|pos| pos + "<head>".len())
|
.map(|pos| pos + "<head>".len())
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
let pos = nonced.find("<head ")?;
|
let pos = html.find("<head ")?;
|
||||||
nonced[pos..].find('>').map(|close| pos + close + 1)
|
html[pos..].find('>').map(|close| pos + close + 1)
|
||||||
});
|
});
|
||||||
if let Some(insert_at) = insert_at {
|
if let Some(insert_at) = insert_at {
|
||||||
let mut result = String::with_capacity(nonced.len() + script_tag.len() + 3);
|
let mut result = String::with_capacity(html.len() + script_tag.len() + 3);
|
||||||
result.push_str(&nonced[..insert_at]);
|
result.push_str(&html[..insert_at]);
|
||||||
result.push_str("\n\t\t");
|
result.push_str("\n\t\t");
|
||||||
result.push_str(script_tag);
|
result.push_str(script_tag);
|
||||||
result.push_str(&nonced[insert_at..]);
|
result.push_str(&html[insert_at..]);
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
nonced
|
html
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_static_preconnect(mut html: String, static_cdn: &str) -> String {
|
fn apply_static_preconnect(mut html: String, static_cdn: &str) -> String {
|
||||||
@@ -219,7 +210,6 @@ mod tests {
|
|||||||
fn inject_into_shipped_shell() -> String {
|
fn inject_into_shipped_shell() -> String {
|
||||||
inject_bootstrap(
|
inject_bootstrap(
|
||||||
SHIPPED_APP_SHELL,
|
SHIPPED_APP_SHELL,
|
||||||
"shellnonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
"https://media.example.test/",
|
"https://media.example.test/",
|
||||||
@@ -256,16 +246,20 @@ mod tests {
|
|||||||
let result = inject_into_shipped_shell();
|
let result = inject_into_shipped_shell();
|
||||||
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
|
assert!(!result.contains("{{STATIC_CDN_ENDPOINT}}"));
|
||||||
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
|
assert!(!result.contains("{{MEDIA_ENDPOINT}}"));
|
||||||
assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}"));
|
|
||||||
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
|
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
|
||||||
assert!(result.contains("<script>boot</script>"));
|
assert!(result.contains("<script>boot</script>"));
|
||||||
assert!(result.contains(r#"nonce="shellnonce""#));
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shipped_shell_carries_no_nonce_attribute_or_placeholder() {
|
||||||
|
assert!(!SHIPPED_APP_SHELL.contains("nonce"));
|
||||||
|
assert!(!SHIPPED_APP_SHELL.contains("{{CSP_NONCE_PLACEHOLDER}}"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_before_head_close() {
|
fn inject_bootstrap_before_head_close() {
|
||||||
let html = "<html><head><title>App</title></head><body></body></html>";
|
let html = "<html><head><title>App</title></head><body></body></html>";
|
||||||
let result = inject_bootstrap(html, "abc123", "<script>boot</script>", "", "");
|
let result = inject_bootstrap(html, "<script>boot</script>", "", "");
|
||||||
assert!(result.contains("<script>boot</script>"));
|
assert!(result.contains("<script>boot</script>"));
|
||||||
assert!(result.contains("<head>"));
|
assert!(result.contains("<head>"));
|
||||||
}
|
}
|
||||||
@@ -273,7 +267,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_fluxer_placeholder() {
|
fn inject_bootstrap_fluxer_placeholder() {
|
||||||
let html = "<html><head>{{FLUXER_BOOTSTRAP}}</head></html>";
|
let html = "<html><head>{{FLUXER_BOOTSTRAP}}</head></html>";
|
||||||
let result = inject_bootstrap(html, "n1", "<script>x</script>", "", "");
|
let result = inject_bootstrap(html, "<script>x</script>", "", "");
|
||||||
assert!(result.contains("<script>x</script>"));
|
assert!(result.contains("<script>x</script>"));
|
||||||
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
|
assert!(!result.contains("{{FLUXER_BOOTSTRAP}}"));
|
||||||
}
|
}
|
||||||
@@ -281,25 +275,16 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_comment_placeholder() {
|
fn inject_bootstrap_comment_placeholder() {
|
||||||
let html = "<html><head><!--{{FLUXER_BOOTSTRAP}}--></head></html>";
|
let html = "<html><head><!--{{FLUXER_BOOTSTRAP}}--></head></html>";
|
||||||
let result = inject_bootstrap(html, "n2", "<script>y</script>", "", "");
|
let result = inject_bootstrap(html, "<script>y</script>", "", "");
|
||||||
assert!(result.contains("<script>y</script>"));
|
assert!(result.contains("<script>y</script>"));
|
||||||
assert!(!result.contains("<!--{{FLUXER_BOOTSTRAP}}-->"));
|
assert!(!result.contains("<!--{{FLUXER_BOOTSTRAP}}-->"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn inject_bootstrap_replaces_csp_nonce_placeholder() {
|
|
||||||
let html = r#"<html><head><script nonce="{{CSP_NONCE_PLACEHOLDER}}"></script>{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
|
||||||
let result = inject_bootstrap(html, "mynonce", "<script>z</script>", "", "");
|
|
||||||
assert!(result.contains(r#"nonce="mynonce""#));
|
|
||||||
assert!(!result.contains("{{CSP_NONCE_PLACEHOLDER}}"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn inject_bootstrap_replaces_static_cdn_endpoint_placeholder() {
|
fn inject_bootstrap_replaces_static_cdn_endpoint_placeholder() {
|
||||||
let html = r#"<html><head><link href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
let html = r#"<html><head><link href="{{STATIC_CDN_ENDPOINT}}/web/favicon-32x32.png">{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
let result = inject_bootstrap(
|
let result = inject_bootstrap(
|
||||||
html,
|
html,
|
||||||
"nonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
"",
|
"",
|
||||||
@@ -315,7 +300,6 @@ mod tests {
|
|||||||
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
let result = inject_bootstrap(
|
let result = inject_bootstrap(
|
||||||
html,
|
html,
|
||||||
"nonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
"https://media.example.test/",
|
"https://media.example.test/",
|
||||||
@@ -332,7 +316,6 @@ mod tests {
|
|||||||
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
let result = inject_bootstrap(
|
let result = inject_bootstrap(
|
||||||
html,
|
html,
|
||||||
"nonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test",
|
"https://cdn.example.test",
|
||||||
"",
|
"",
|
||||||
@@ -349,7 +332,6 @@ mod tests {
|
|||||||
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
{{FLUXER_BOOTSTRAP}}</head></html>"#;
|
||||||
let result = inject_bootstrap(
|
let result = inject_bootstrap(
|
||||||
html,
|
html,
|
||||||
"nonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test",
|
"https://cdn.example.test",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
@@ -367,7 +349,6 @@ mod tests {
|
|||||||
fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() {
|
fn static_cdn_keeps_a_credentialed_and_an_anonymous_preconnect() {
|
||||||
let result = inject_bootstrap(
|
let result = inject_bootstrap(
|
||||||
SHELL_PRECONNECT_HEAD,
|
SHELL_PRECONNECT_HEAD,
|
||||||
"nonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
"https://media.example.test",
|
"https://media.example.test",
|
||||||
@@ -384,7 +365,6 @@ mod tests {
|
|||||||
fn both_static_preconnects_are_dropped_when_the_endpoint_is_empty() {
|
fn both_static_preconnects_are_dropped_when_the_endpoint_is_empty() {
|
||||||
let result = inject_bootstrap(
|
let result = inject_bootstrap(
|
||||||
SHELL_PRECONNECT_HEAD,
|
SHELL_PRECONNECT_HEAD,
|
||||||
"nonce",
|
|
||||||
"<script>boot</script>",
|
"<script>boot</script>",
|
||||||
"",
|
"",
|
||||||
"https://media.example.test",
|
"https://media.example.test",
|
||||||
@@ -422,7 +402,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_payload_serialization_field_names() {
|
fn bootstrap_payload_serialization_field_names() {
|
||||||
let instance = serde_json::json!({"name": "test"});
|
let instance = serde_json::json!({"name": "test"});
|
||||||
let geoip = serde_json::json!({"country": "SE"});
|
|
||||||
let payload = BootstrapPayload {
|
let payload = BootstrapPayload {
|
||||||
config: BootstrapConfig {
|
config: BootstrapConfig {
|
||||||
release_channel: "stable",
|
release_channel: "stable",
|
||||||
@@ -430,20 +409,18 @@ mod tests {
|
|||||||
bootstrap_api_public_endpoint: None,
|
bootstrap_api_public_endpoint: None,
|
||||||
},
|
},
|
||||||
instance: &instance,
|
instance: &instance,
|
||||||
geoip: &geoip,
|
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&payload).unwrap();
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
assert!(json.contains(r#""releaseChannel""#));
|
assert!(json.contains(r#""releaseChannel""#));
|
||||||
assert!(json.contains(r#""bootstrapApiEndpoint""#));
|
assert!(json.contains(r#""bootstrapApiEndpoint""#));
|
||||||
assert!(json.contains(r#""config""#));
|
assert!(json.contains(r#""config""#));
|
||||||
assert!(json.contains(r#""instance""#));
|
assert!(json.contains(r#""instance""#));
|
||||||
assert!(json.contains(r#""geoip""#));
|
assert!(!json.contains("geoip"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_config_serializes_public_endpoint_when_present() {
|
fn bootstrap_config_serializes_public_endpoint_when_present() {
|
||||||
let instance = serde_json::json!({});
|
let instance = serde_json::json!({});
|
||||||
let geoip = serde_json::json!({});
|
|
||||||
let payload = BootstrapPayload {
|
let payload = BootstrapPayload {
|
||||||
config: BootstrapConfig {
|
config: BootstrapConfig {
|
||||||
release_channel: "canary",
|
release_channel: "canary",
|
||||||
@@ -451,7 +428,6 @@ mod tests {
|
|||||||
bootstrap_api_public_endpoint: Some("https://pub.example.com/api"),
|
bootstrap_api_public_endpoint: Some("https://pub.example.com/api"),
|
||||||
},
|
},
|
||||||
instance: &instance,
|
instance: &instance,
|
||||||
geoip: &geoip,
|
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&payload).unwrap();
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
assert!(json.contains(r#""bootstrapApiPublicEndpoint""#));
|
assert!(json.contains(r#""bootstrapApiPublicEndpoint""#));
|
||||||
@@ -460,7 +436,6 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn bootstrap_config_omits_public_endpoint_when_none() {
|
fn bootstrap_config_omits_public_endpoint_when_none() {
|
||||||
let instance = serde_json::json!({});
|
let instance = serde_json::json!({});
|
||||||
let geoip = serde_json::json!({});
|
|
||||||
let payload = BootstrapPayload {
|
let payload = BootstrapPayload {
|
||||||
config: BootstrapConfig {
|
config: BootstrapConfig {
|
||||||
release_channel: "stable",
|
release_channel: "stable",
|
||||||
@@ -468,7 +443,6 @@ mod tests {
|
|||||||
bootstrap_api_public_endpoint: None,
|
bootstrap_api_public_endpoint: None,
|
||||||
},
|
},
|
||||||
instance: &instance,
|
instance: &instance,
|
||||||
geoip: &geoip,
|
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&payload).unwrap();
|
let json = serde_json::to_string(&payload).unwrap();
|
||||||
assert!(!json.contains("bootstrapApiPublicEndpoint"));
|
assert!(!json.contains("bootstrapApiPublicEndpoint"));
|
||||||
@@ -611,8 +585,7 @@ mod tests {
|
|||||||
let discovery = discovery_offering("https://chat.example.test:8443/api");
|
let discovery = discovery_offering("https://chat.example.test:8443/api");
|
||||||
let mut config = AppProxyConfig::from_env();
|
let mut config = AppProxyConfig::from_env();
|
||||||
config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned());
|
config.bootstrap_api_public_endpoint = Some("https://chat.example.test/api".to_owned());
|
||||||
let script =
|
let script = build_bootstrap_script(&config, &discovery);
|
||||||
build_bootstrap_script(&config, &discovery, &serde_json::json!({}), "scriptnonce");
|
|
||||||
assert!(
|
assert!(
|
||||||
script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#)
|
script.contains(r#""bootstrapApiPublicEndpoint":"https://chat.example.test:8443/api""#)
|
||||||
);
|
);
|
||||||
@@ -621,4 +594,16 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(!script.contains(r#""https://chat.example.test/api""#));
|
assert!(!script.contains(r#""https://chat.example.test/api""#));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_boot_script_is_a_bare_inline_script_with_nothing_per_visitor() {
|
||||||
|
let discovery = discovery_offering("https://chat.example.test/api");
|
||||||
|
let config = AppProxyConfig::from_env();
|
||||||
|
let script = build_bootstrap_script(&config, &discovery);
|
||||||
|
assert!(script.starts_with("<script>window.__FLUXER_BOOTSTRAP__="));
|
||||||
|
assert!(script.ends_with("</script>"));
|
||||||
|
assert!(!script.contains("nonce"));
|
||||||
|
assert!(!script.contains("geoip"));
|
||||||
|
assert!(!script.contains("countryCode"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+177
-71
@@ -3,15 +3,82 @@
|
|||||||
use crate::config::{AppProxyConfig, CspConfig, CspSource, HttpEndpoint};
|
use crate::config::{AppProxyConfig, CspConfig, CspSource, HttpEndpoint};
|
||||||
use axum::http::HeaderValue;
|
use axum::http::HeaderValue;
|
||||||
use axum::http::header::InvalidHeaderValue;
|
use axum::http::header::InvalidHeaderValue;
|
||||||
use rand::RngExt;
|
use base64::{Engine as _, engine::general_purpose::STANDARD};
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
|
||||||
const CSP_NONCE_HEX_DIGITS: usize = 32;
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
const CSP_VALIDATION_NONCE: &str = "00000000000000000000000000000000";
|
pub struct InlineScriptHash(String);
|
||||||
|
|
||||||
const _: () = assert!(
|
impl InlineScriptHash {
|
||||||
CSP_VALIDATION_NONCE.len() == CSP_NONCE_HEX_DIGITS,
|
pub fn of(script_text: &str) -> Self {
|
||||||
"the nonce a policy is validated with must be shaped like the nonce a request carries"
|
Self(format!(
|
||||||
);
|
"'sha256-{}'",
|
||||||
|
STANDARD.encode(Sha256::digest(script_text.as_bytes()))
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn as_source(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inline_script_hashes(document: &str) -> Vec<InlineScriptHash> {
|
||||||
|
let mut hashes: Vec<InlineScriptHash> = Vec::new();
|
||||||
|
let mut rest = document;
|
||||||
|
while let Some((attributes, text, after)) = next_script_element(rest) {
|
||||||
|
rest = after;
|
||||||
|
if has_src_attribute(attributes) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let hash = InlineScriptHash::of(text);
|
||||||
|
if !hashes.contains(&hash) {
|
||||||
|
hashes.push(hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
hashes
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_script_element(html: &str) -> Option<(&str, &str, &str)> {
|
||||||
|
let mut offset = 0;
|
||||||
|
loop {
|
||||||
|
let start = offset + find_ignoring_ascii_case(&html[offset..], "<script")?;
|
||||||
|
let name_end = start + "<script".len();
|
||||||
|
let boundary = *html.as_bytes().get(name_end)?;
|
||||||
|
if boundary != b'>' && boundary != b'/' && !boundary.is_ascii_whitespace() {
|
||||||
|
offset = name_end;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let tag_end = name_end + html[name_end..].find('>')?;
|
||||||
|
let text_start = tag_end + 1;
|
||||||
|
let text_end = text_start + find_ignoring_ascii_case(&html[text_start..], "</script")?;
|
||||||
|
let close_end = html[text_end..]
|
||||||
|
.find('>')
|
||||||
|
.map_or(html.len(), |index| text_end + index + 1);
|
||||||
|
return Some((
|
||||||
|
&html[name_end..tag_end],
|
||||||
|
&html[text_start..text_end],
|
||||||
|
&html[close_end..],
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_ignoring_ascii_case(haystack: &str, needle: &str) -> Option<usize> {
|
||||||
|
haystack
|
||||||
|
.as_bytes()
|
||||||
|
.windows(needle.len())
|
||||||
|
.position(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn has_src_attribute(attributes: &str) -> bool {
|
||||||
|
attributes
|
||||||
|
.split(|c: char| c.is_ascii_whitespace() || c == '/')
|
||||||
|
.any(|token| {
|
||||||
|
token
|
||||||
|
.split('=')
|
||||||
|
.next()
|
||||||
|
.is_some_and(|name| name.eq_ignore_ascii_case("src"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default)]
|
#[derive(Clone, Debug, Default)]
|
||||||
pub struct RuntimeCspSources {
|
pub struct RuntimeCspSources {
|
||||||
@@ -129,7 +196,7 @@ impl CompiledCspPolicy {
|
|||||||
.map_err(CspCompileError::InvalidAssetPolicy)?;
|
.map_err(CspCompileError::InvalidAssetPolicy)?;
|
||||||
HeaderValue::from_str(&build_csp(
|
HeaderValue::from_str(&build_csp(
|
||||||
&config,
|
&config,
|
||||||
CSP_VALIDATION_NONCE,
|
&[InlineScriptHash::of("")],
|
||||||
configured_sources,
|
configured_sources,
|
||||||
))
|
))
|
||||||
.map_err(CspCompileError::InvalidSpaPolicy)?;
|
.map_err(CspCompileError::InvalidSpaPolicy)?;
|
||||||
@@ -140,26 +207,24 @@ impl CompiledCspPolicy {
|
|||||||
self.asset.clone()
|
self.asset.clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn spa_header(&self, nonce: &str, runtime_sources: &RuntimeCspSources) -> HeaderValue {
|
pub fn spa_header(
|
||||||
assert!(
|
&self,
|
||||||
nonce.len() == CSP_NONCE_HEX_DIGITS
|
script_hashes: &[InlineScriptHash],
|
||||||
&& nonce.bytes().all(|byte| byte.is_ascii_hexdigit()),
|
runtime_sources: &RuntimeCspSources,
|
||||||
"a CSP nonce must be a 128-bit hexadecimal value"
|
) -> HeaderValue {
|
||||||
);
|
HeaderValue::from_str(&build_csp(&self.config, script_hashes, runtime_sources)).expect(
|
||||||
HeaderValue::from_str(&build_csp(&self.config, nonce, runtime_sources)).expect(
|
"every CSP source is a validated keyword, scheme, ASCII origin, or base64 hash, so a \
|
||||||
"every CSP source is a validated keyword, scheme, or ASCII origin, so a policy built \
|
policy built from them is always a valid header value",
|
||||||
from them is always a valid header value",
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn generate_nonce() -> String {
|
fn build_csp(
|
||||||
let bytes: [u8; 16] = rand::rng().random();
|
config: &CspConfig,
|
||||||
hex::encode(bytes)
|
script_hashes: &[InlineScriptHash],
|
||||||
}
|
runtime_sources: &RuntimeCspSources,
|
||||||
|
) -> String {
|
||||||
fn build_csp(config: &CspConfig, nonce: &str, runtime_sources: &RuntimeCspSources) -> String {
|
build_csp_directives(config, Some(script_hashes), runtime_sources).join("; ")
|
||||||
build_csp_directives(config, Some(nonce), runtime_sources).join("; ")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String {
|
fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> String {
|
||||||
@@ -168,7 +233,7 @@ fn build_asset_csp(config: &CspConfig, runtime_sources: &RuntimeCspSources) -> S
|
|||||||
|
|
||||||
fn build_csp_directives(
|
fn build_csp_directives(
|
||||||
config: &CspConfig,
|
config: &CspConfig,
|
||||||
nonce: Option<&str>,
|
script_hashes: Option<&[InlineScriptHash]>,
|
||||||
runtime_sources: &RuntimeCspSources,
|
runtime_sources: &RuntimeCspSources,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
let mut directives = Vec::with_capacity(14);
|
let mut directives = Vec::with_capacity(14);
|
||||||
@@ -182,8 +247,8 @@ fn build_csp_directives(
|
|||||||
"'wasm-unsafe-eval'".to_owned(),
|
"'wasm-unsafe-eval'".to_owned(),
|
||||||
"blob:".to_owned(),
|
"blob:".to_owned(),
|
||||||
];
|
];
|
||||||
if let Some(n) = nonce {
|
if let Some(hashes) = script_hashes {
|
||||||
script.insert(1, format!("'nonce-{n}'"));
|
script.splice(1..1, hashes.iter().map(|hash| hash.as_source().to_owned()));
|
||||||
}
|
}
|
||||||
extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES);
|
extend_from(&mut script, &config.extra_script_src, SCRIPT_SOURCES);
|
||||||
extend_runtime_sources(&mut script, runtime_sources, true, false);
|
extend_runtime_sources(&mut script, runtime_sources, true, false);
|
||||||
@@ -290,21 +355,52 @@ fn extend_from(target: &mut Vec<String>, extra: &[CspSource], defaults: &[&str])
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
fn hash_of(text: &str) -> InlineScriptHash {
|
||||||
fn generate_nonce_produces_32_char_hex() {
|
InlineScriptHash::of(text)
|
||||||
let nonce = generate_nonce();
|
|
||||||
assert_eq!(nonce.len(), CSP_NONCE_HEX_DIGITS);
|
|
||||||
assert!(nonce.chars().all(|c| c.is_ascii_hexdigit()));
|
|
||||||
CompiledCspPolicy::compile(default_csp_config(), &runtime_sources())
|
|
||||||
.unwrap()
|
|
||||||
.spa_header(&nonce, &runtime_sources());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn generate_nonce_is_random() {
|
fn an_inline_script_hash_is_the_base64_sha256_of_the_exact_script_text() {
|
||||||
let a = generate_nonce();
|
assert_eq!(
|
||||||
let b = generate_nonce();
|
hash_of("alert('Hello, world.');").as_source(),
|
||||||
assert_ne!(a, b);
|
"'sha256-qznLcsROx4GACP2dm0UCKCzCG+HiZ1guq6ZZDob/Tng='"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
hash_of("").as_source(),
|
||||||
|
"'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_inline_script_is_hashed_and_external_scripts_are_not() {
|
||||||
|
let document = concat!(
|
||||||
|
"<head><script>first()</script>",
|
||||||
|
"<SCRIPT type=\"text/javascript\">second()</SCRIPT >",
|
||||||
|
"<script type=\"module\" src=\"/assets/app.js\"></script>",
|
||||||
|
"<script defer src='/assets/vendor.js'></script>",
|
||||||
|
"<scripts>not a script</scripts>",
|
||||||
|
"<script data-src=\"x\">\nthird()\n</script></head>",
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
inline_script_hashes(document),
|
||||||
|
vec![
|
||||||
|
hash_of("first()"),
|
||||||
|
hash_of("second()"),
|
||||||
|
hash_of("\nthird()\n")
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_inline_script_repeated_verbatim_is_granted_once() {
|
||||||
|
let document = "<script>same()</script><script>same()</script>";
|
||||||
|
assert_eq!(inline_script_hashes(document), vec![hash_of("same()")]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_unterminated_script_is_never_granted() {
|
||||||
|
assert!(inline_script_hashes("<script>never_closed()").is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_csp_config() -> CspConfig {
|
fn default_csp_config() -> CspConfig {
|
||||||
@@ -322,7 +418,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn build_csp_includes_required_directives() {
|
fn build_csp_includes_required_directives() {
|
||||||
let config = default_csp_config();
|
let config = default_csp_config();
|
||||||
let csp = build_csp(&config, "testnonce", &runtime_sources());
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||||
assert!(csp.contains("default-src"));
|
assert!(csp.contains("default-src"));
|
||||||
assert!(csp.contains("script-src"));
|
assert!(csp.contains("script-src"));
|
||||||
assert!(csp.contains("style-src"));
|
assert!(csp.contains("style-src"));
|
||||||
@@ -341,7 +437,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn build_csp_allows_blob_connections_for_camera_background_media() {
|
fn build_csp_allows_blob_connections_for_camera_background_media() {
|
||||||
let config = default_csp_config();
|
let config = default_csp_config();
|
||||||
let csp = build_csp(&config, "testnonce", &runtime_sources());
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||||
let connect = csp
|
let connect = csp
|
||||||
.split("; ")
|
.split("; ")
|
||||||
.find(|directive| directive.starts_with("connect-src "))
|
.find(|directive| directive.starts_with("connect-src "))
|
||||||
@@ -369,22 +465,40 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_csp_includes_nonce_in_script_src() {
|
fn build_csp_grants_each_script_hash_in_script_src_and_no_nonce() {
|
||||||
let config = default_csp_config();
|
let config = default_csp_config();
|
||||||
let csp = build_csp(&config, "abc123def456", &runtime_sources());
|
let csp = build_csp(
|
||||||
assert!(csp.contains("'nonce-abc123def456'"));
|
&config,
|
||||||
}
|
&[hash_of("first()"), hash_of("second()")],
|
||||||
|
&runtime_sources(),
|
||||||
#[test]
|
);
|
||||||
fn build_asset_csp_excludes_nonce() {
|
let script = csp
|
||||||
let config = default_csp_config();
|
.split("; ")
|
||||||
let csp = build_asset_csp(&config, &runtime_sources());
|
.find(|directive| directive.starts_with("script-src "))
|
||||||
|
.expect("script-src directive");
|
||||||
|
assert!(script.starts_with(&format!(
|
||||||
|
"script-src 'self' {} {} 'wasm-unsafe-eval' blob:",
|
||||||
|
hash_of("first()").as_source(),
|
||||||
|
hash_of("second()").as_source()
|
||||||
|
)));
|
||||||
assert!(!csp.contains("nonce-"));
|
assert!(!csp.contains("nonce-"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_asset_csp_grants_no_inline_script() {
|
||||||
|
let config = default_csp_config();
|
||||||
|
let csp = build_asset_csp(&config, &runtime_sources());
|
||||||
|
assert!(!csp.contains("nonce-"));
|
||||||
|
assert!(!csp.contains("sha256-"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_csp_allows_no_third_party_captcha_hosts() {
|
fn build_csp_allows_no_third_party_captcha_hosts() {
|
||||||
let csp = build_csp(&default_csp_config(), "test-nonce", &runtime_sources());
|
let csp = build_csp(
|
||||||
|
&default_csp_config(),
|
||||||
|
&[hash_of("boot()")],
|
||||||
|
&runtime_sources(),
|
||||||
|
);
|
||||||
assert!(!csp.contains("hcaptcha"));
|
assert!(!csp.contains("hcaptcha"));
|
||||||
assert!(!csp.contains("challenges.cloudflare.com"));
|
assert!(!csp.contains("challenges.cloudflare.com"));
|
||||||
}
|
}
|
||||||
@@ -392,7 +506,7 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn csp_no_double_spaces_or_trailing_semicolons() {
|
fn csp_no_double_spaces_or_trailing_semicolons() {
|
||||||
let config = default_csp_config();
|
let config = default_csp_config();
|
||||||
let csp = build_csp(&config, "nonce1", &runtime_sources());
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||||
assert!(!csp.contains(" "), "CSP contains double spaces");
|
assert!(!csp.contains(" "), "CSP contains double spaces");
|
||||||
assert!(!csp.ends_with(';'), "CSP ends with semicolon");
|
assert!(!csp.ends_with(';'), "CSP ends with semicolon");
|
||||||
assert!(!csp.ends_with("; "), "CSP ends with semicolon+space");
|
assert!(!csp.ends_with("; "), "CSP ends with semicolon+space");
|
||||||
@@ -410,14 +524,14 @@ mod tests {
|
|||||||
),
|
),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let csp = build_csp(&config, "nonce1", &runtime_sources());
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||||
assert!(csp.contains("report-uri https://example.com/csp-report"));
|
assert!(csp.contains("report-uri https://example.com/csp-report"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_csp_excludes_report_uri_when_none() {
|
fn build_csp_excludes_report_uri_when_none() {
|
||||||
let config = default_csp_config();
|
let config = default_csp_config();
|
||||||
let csp = build_csp(&config, "nonce1", &runtime_sources());
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources());
|
||||||
assert!(!csp.contains("report-uri"));
|
assert!(!csp.contains("report-uri"));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -429,7 +543,7 @@ mod tests {
|
|||||||
media_endpoint: Some(endpoint("https://media.example.test")),
|
media_endpoint: Some(endpoint("https://media.example.test")),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
let csp = build_csp(&config, "nonce1", &runtime_sources);
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources);
|
||||||
assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
|
assert!(csp.contains("style-src 'self' 'unsafe-inline'"));
|
||||||
assert!(csp.contains("https://static.example.test"));
|
assert!(csp.contains("https://static.example.test"));
|
||||||
assert!(csp.contains("https://media.example.test"));
|
assert!(csp.contains("https://media.example.test"));
|
||||||
@@ -471,7 +585,7 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let csp = build_csp(&config, "nonce1", &runtime_sources);
|
let csp = build_csp(&config, &[hash_of("boot()")], &runtime_sources);
|
||||||
|
|
||||||
assert!(csp.contains("http://localhost:3900"));
|
assert!(csp.contains("http://localhost:3900"));
|
||||||
assert!(csp.contains("http://fluxer-uploads.localhost:3900"));
|
assert!(csp.contains("http://fluxer-uploads.localhost:3900"));
|
||||||
@@ -492,6 +606,7 @@ mod tests {
|
|||||||
let asset = policy.asset_header();
|
let asset = policy.asset_header();
|
||||||
let asset = asset.to_str().unwrap();
|
let asset = asset.to_str().unwrap();
|
||||||
assert!(!asset.contains("nonce-"));
|
assert!(!asset.contains("nonce-"));
|
||||||
|
assert!(!asset.contains("sha256-"));
|
||||||
assert!(asset.contains("https://static.example.test"));
|
assert!(asset.contains("https://static.example.test"));
|
||||||
assert!(
|
assert!(
|
||||||
!asset.contains("https://media.example.test"),
|
!asset.contains("https://media.example.test"),
|
||||||
@@ -501,7 +616,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn a_compiled_policy_stamps_the_requests_own_nonce_and_discovery_endpoints() {
|
fn a_compiled_policy_stamps_the_documents_script_hashes_and_discovery_endpoints() {
|
||||||
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
|
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
|
||||||
let discovered = RuntimeCspSources {
|
let discovered = RuntimeCspSources {
|
||||||
static_cdn_endpoint: Some(endpoint("https://cdn.discovered.test")),
|
static_cdn_endpoint: Some(endpoint("https://cdn.discovered.test")),
|
||||||
@@ -509,10 +624,10 @@ mod tests {
|
|||||||
..Default::default()
|
..Default::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
let header = policy.spa_header("0123456789abcdef0123456789abcdef", &discovered);
|
let header = policy.spa_header(&[hash_of("boot()")], &discovered);
|
||||||
let header = header.to_str().unwrap();
|
let header = header.to_str().unwrap();
|
||||||
|
|
||||||
assert!(header.contains("'nonce-0123456789abcdef0123456789abcdef'"));
|
assert!(header.contains(hash_of("boot()").as_source()));
|
||||||
assert!(header.contains("https://cdn.discovered.test"));
|
assert!(header.contains("https://cdn.discovered.test"));
|
||||||
assert!(header.contains("https://branding.discovered.test"));
|
assert!(header.contains("https://branding.discovered.test"));
|
||||||
}
|
}
|
||||||
@@ -530,19 +645,10 @@ mod tests {
|
|||||||
policy.asset_header().to_str().unwrap(),
|
policy.asset_header().to_str().unwrap(),
|
||||||
build_asset_csp(&config, &sources)
|
build_asset_csp(&config, &sources)
|
||||||
);
|
);
|
||||||
|
let hashes = [hash_of("boot()")];
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
policy
|
policy.spa_header(&hashes, &sources).to_str().unwrap(),
|
||||||
.spa_header(CSP_VALIDATION_NONCE, &sources)
|
build_csp(&config, &hashes, &sources)
|
||||||
.to_str()
|
|
||||||
.unwrap(),
|
|
||||||
build_csp(&config, CSP_VALIDATION_NONCE, &sources)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
|
||||||
#[should_panic(expected = "a CSP nonce must be a 128-bit hexadecimal value")]
|
|
||||||
fn a_compiled_policy_refuses_a_nonce_it_did_not_generate() {
|
|
||||||
let policy = CompiledCspPolicy::compile(default_csp_config(), &runtime_sources()).unwrap();
|
|
||||||
policy.spa_header("not-a-nonce", &runtime_sources());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
|
use crate::geoip::build_geoip_response;
|
||||||
|
use crate::state::AppState;
|
||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::State,
|
||||||
|
http::{HeaderMap, HeaderValue, header},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const CLIENT_GEOIP_PATH: &str = "/_geoip";
|
||||||
|
const CLIENT_GEOIP_CACHE_CONTROL: &str = "no-store, private";
|
||||||
|
|
||||||
|
pub async fn client_geoip(State(state): State<AppState>, headers: HeaderMap) -> Response {
|
||||||
|
let mut response = Json(build_geoip_response(state.geoip.lookup(&headers))).into_response();
|
||||||
|
response.headers_mut().insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static(CLIENT_GEOIP_CACHE_CONTROL),
|
||||||
|
);
|
||||||
|
response
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::AppProxyConfig;
|
||||||
|
use crate::discovery_cache::DiscoveryCache;
|
||||||
|
use crate::routes::build_router;
|
||||||
|
use axum::body::Body;
|
||||||
|
use axum::http::{Request, StatusCode};
|
||||||
|
use fluxer_common::config::GeoipSourceConfig;
|
||||||
|
use fluxer_common::geoip::{GeoipConfig, GeoipLookup, GeoipResolver};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tower::ServiceExt;
|
||||||
|
|
||||||
|
fn geoip_state() -> AppState {
|
||||||
|
let config = AppProxyConfig::from_env();
|
||||||
|
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: true,
|
||||||
|
client_ip_header_name: "x-forwarded-for".to_owned(),
|
||||||
|
})),
|
||||||
|
index_html: Some(Arc::from("<html><head></head><body></body></html>")),
|
||||||
|
budgets: crate::state::AppProxyBudgets::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_geoip_endpoint_answers_for_the_requesting_client_and_is_never_stored() {
|
||||||
|
let response = build_router(geoip_state())
|
||||||
|
.oneshot(
|
||||||
|
Request::get(CLIENT_GEOIP_PATH)
|
||||||
|
.header("x-forwarded-for", "203.0.113.10")
|
||||||
|
.body(Body::empty())
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let headers = response.headers();
|
||||||
|
assert_eq!(
|
||||||
|
headers.get(header::CACHE_CONTROL).unwrap(),
|
||||||
|
CLIENT_GEOIP_CACHE_CONTROL
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
headers
|
||||||
|
.get(header::CONTENT_TYPE)
|
||||||
|
.unwrap()
|
||||||
|
.to_str()
|
||||||
|
.unwrap()
|
||||||
|
.starts_with("application/json"),
|
||||||
|
"the SPA fallback answered the geoip endpoint with the app shell"
|
||||||
|
);
|
||||||
|
assert!(headers.get("x-fluxer-app-shell").is_none());
|
||||||
|
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let body: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert_eq!(body, build_geoip_response(GeoipLookup::default()));
|
||||||
|
assert_eq!(body["countryCode"], serde_json::Value::Null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
mod android_association;
|
mod android_association;
|
||||||
mod apple_association;
|
mod apple_association;
|
||||||
mod assets_proxy;
|
mod assets_proxy;
|
||||||
|
mod client_geoip;
|
||||||
mod file_stream;
|
mod file_stream;
|
||||||
mod health;
|
mod health;
|
||||||
mod spa_index;
|
mod spa_index;
|
||||||
@@ -35,6 +36,10 @@ pub fn build_router(state: AppState) -> Router {
|
|||||||
Router::new()
|
Router::new()
|
||||||
.route("/_health", get(health::health))
|
.route("/_health", get(health::health))
|
||||||
.route("/_ready", get(health::ready))
|
.route("/_ready", get(health::ready))
|
||||||
|
.route(
|
||||||
|
client_geoip::CLIENT_GEOIP_PATH,
|
||||||
|
get(client_geoip::client_geoip),
|
||||||
|
)
|
||||||
.route(
|
.route(
|
||||||
"/.well-known/apple-app-site-association",
|
"/.well-known/apple-app-site-association",
|
||||||
get(apple_association::apple_app_site_association),
|
get(apple_association::apple_app_site_association),
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ use crate::bootstrap::{
|
|||||||
build_bootstrap_script, inject_bootstrap, rewrite_endpoints_for_same_origin_host,
|
build_bootstrap_script, inject_bootstrap, rewrite_endpoints_for_same_origin_host,
|
||||||
};
|
};
|
||||||
use crate::config::{AppProxyConfig, HttpEndpoint};
|
use crate::config::{AppProxyConfig, HttpEndpoint};
|
||||||
use crate::csp::{RuntimeCspSources, generate_nonce};
|
use crate::csp::{RuntimeCspSources, inline_script_hashes};
|
||||||
use crate::discovery_cache::{DiscoveryResponse, discovery_endpoint};
|
use crate::discovery_cache::{DiscoveryResponse, discovery_endpoint};
|
||||||
use crate::geoip::build_geoip_response;
|
|
||||||
use crate::state::{
|
use crate::state::{
|
||||||
AppProxyBudgets, AppState, MAX_RENDERED_SPA_INDEX_BYTES, MAX_SPA_INDEX_BYTES,
|
AppProxyBudgets, AppState, MAX_RENDERED_SPA_INDEX_BYTES, MAX_SPA_INDEX_BYTES,
|
||||||
read_bounded_text_file,
|
read_bounded_text_file,
|
||||||
@@ -27,6 +26,7 @@ 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 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 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";
|
const DEV_NO_STORE_CACHE_CONTROL: &str = "no-store, no-cache, must-revalidate, max-age=0";
|
||||||
|
const SHARED_SHELL_CACHE_CONTROL: &str = "public, max-age=0, s-maxage=1";
|
||||||
|
|
||||||
pub async fn spa_catch_all(
|
pub async fn spa_catch_all(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
@@ -154,7 +154,6 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
|
|||||||
rewrite_endpoints_for_same_origin_host(&mut discovery.data, host);
|
rewrite_endpoints_for_same_origin_host(&mut discovery.data, host);
|
||||||
}
|
}
|
||||||
|
|
||||||
let nonce = generate_nonce();
|
|
||||||
let runtime_csp_sources = build_runtime_csp_sources(state, &discovery);
|
let runtime_csp_sources = build_runtime_csp_sources(state, &discovery);
|
||||||
let static_cdn_endpoint = runtime_csp_sources
|
let static_cdn_endpoint = runtime_csp_sources
|
||||||
.static_cdn_endpoint
|
.static_cdn_endpoint
|
||||||
@@ -164,9 +163,7 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
|
|||||||
.media_endpoint
|
.media_endpoint
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or("", HttpEndpoint::as_str);
|
.map_or("", HttpEndpoint::as_str);
|
||||||
let csp = state.csp.spa_header(&nonce, &runtime_csp_sources);
|
let script_tag = build_bootstrap_script(&state.config, &discovery);
|
||||||
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 {
|
let raw_html = match load_spa_index_html(state).await {
|
||||||
Ok(content) => content,
|
Ok(content) => content,
|
||||||
@@ -181,7 +178,6 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
|
|||||||
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
|
let dev_buster = should_bust_dev_assets.then(current_dev_asset_cache_buster);
|
||||||
let html = match render_spa_document(
|
let html = match render_spa_document(
|
||||||
&raw_html,
|
&raw_html,
|
||||||
&nonce,
|
|
||||||
&script_tag,
|
&script_tag,
|
||||||
static_cdn_endpoint,
|
static_cdn_endpoint,
|
||||||
media_endpoint,
|
media_endpoint,
|
||||||
@@ -193,8 +189,10 @@ async fn serve_spa_index(state: &AppState, headers: &HeaderMap) -> Response {
|
|||||||
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
return StatusCode::INTERNAL_SERVER_ERROR.into_response();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let html = html.into_boxed_str();
|
let csp = state
|
||||||
build_spa_response(html, csp, should_bust_dev_assets)
|
.csp
|
||||||
|
.spa_header(&inline_script_hashes(&html), &runtime_csp_sources);
|
||||||
|
build_spa_response(html.into_boxed_str(), csp, should_bust_dev_assets)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn same_origin_host<'a>(config: &'a AppProxyConfig, headers: &HeaderMap) -> Option<&'a str> {
|
fn same_origin_host<'a>(config: &'a AppProxyConfig, headers: &HeaderMap) -> Option<&'a str> {
|
||||||
@@ -248,7 +246,6 @@ fn bounded_document(document: String) -> Result<String, SpaDocumentSizeLimitErro
|
|||||||
|
|
||||||
fn render_spa_document(
|
fn render_spa_document(
|
||||||
html: &str,
|
html: &str,
|
||||||
nonce: &str,
|
|
||||||
script_tag: &str,
|
script_tag: &str,
|
||||||
static_cdn_endpoint: &str,
|
static_cdn_endpoint: &str,
|
||||||
media_endpoint: &str,
|
media_endpoint: &str,
|
||||||
@@ -256,7 +253,6 @@ fn render_spa_document(
|
|||||||
) -> Result<String, SpaDocumentSizeLimitError> {
|
) -> Result<String, SpaDocumentSizeLimitError> {
|
||||||
let mut document = bounded_document(inject_bootstrap(
|
let mut document = bounded_document(inject_bootstrap(
|
||||||
html,
|
html,
|
||||||
nonce,
|
|
||||||
script_tag,
|
script_tag,
|
||||||
static_cdn_endpoint,
|
static_cdn_endpoint,
|
||||||
media_endpoint,
|
media_endpoint,
|
||||||
@@ -457,7 +453,10 @@ fn build_spa_response(html: Box<str>, csp: HeaderValue, dev_no_store: bool) -> R
|
|||||||
HeaderValue::from_static("no-store"),
|
HeaderValue::from_static("no-store"),
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
|
headers.insert(
|
||||||
|
header::CACHE_CONTROL,
|
||||||
|
HeaderValue::from_static(SHARED_SHELL_CACHE_CONTROL),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
super::set_security_headers(headers);
|
super::set_security_headers(headers);
|
||||||
headers.insert(
|
headers.insert(
|
||||||
@@ -664,13 +663,12 @@ mod tests {
|
|||||||
assert!(!is_static_root_file("/users/1.2.3"));
|
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>"#;
|
const SHELL_WITH_AN_INLINE_SCRIPT: &str = r#"<!doctype html><html><head><title>Fluxer</title><script>inline()</script><script src="/assets/app.js"></script></head><body></body></html>"#;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_rendered_document_always_carries_the_bootstrap_and_a_real_nonce() {
|
fn the_rendered_document_always_carries_the_bootstrap() {
|
||||||
let rendered = render_spa_document(
|
let rendered = render_spa_document(
|
||||||
SHELL_WITH_A_NONCE_HOLE,
|
SHELL_WITH_AN_INLINE_SCRIPT,
|
||||||
"reqnonce",
|
|
||||||
"<script>booted</script>",
|
"<script>booted</script>",
|
||||||
"https://static.example.test",
|
"https://static.example.test",
|
||||||
"",
|
"",
|
||||||
@@ -678,16 +676,15 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("test SPA document must render within its size limit");
|
.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>"));
|
assert!(rendered.contains("<script>booted</script>"));
|
||||||
|
assert!(rendered.contains("<script>inline()</script>"));
|
||||||
|
assert!(!rendered.contains("nonce"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_dev_cache_buster_reaches_the_rendered_document_only_when_supplied() {
|
fn the_dev_cache_buster_reaches_the_rendered_document_only_when_supplied() {
|
||||||
let busted = render_spa_document(
|
let busted = render_spa_document(
|
||||||
SHELL_WITH_A_NONCE_HOLE,
|
SHELL_WITH_AN_INLINE_SCRIPT,
|
||||||
"reqnonce",
|
|
||||||
"<script>booted</script>",
|
"<script>booted</script>",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -695,8 +692,7 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.expect("test SPA document must render within its size limit");
|
.expect("test SPA document must render within its size limit");
|
||||||
let untouched = render_spa_document(
|
let untouched = render_spa_document(
|
||||||
SHELL_WITH_A_NONCE_HOLE,
|
SHELL_WITH_AN_INLINE_SCRIPT,
|
||||||
"reqnonce",
|
|
||||||
"<script>booted</script>",
|
"<script>booted</script>",
|
||||||
"",
|
"",
|
||||||
"",
|
"",
|
||||||
@@ -712,13 +708,12 @@ mod tests {
|
|||||||
const SHELL_WITH_ENDPOINT_HOLES: &str = r#"<!doctype html><html><head><title>Fluxer</title><link rel="preconnect" href="{{STATIC_CDN_ENDPOINT}}">
|
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="{{STATIC_CDN_ENDPOINT}}" crossorigin>
|
||||||
<link rel="preconnect" href="{{MEDIA_ENDPOINT}}">
|
<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>"#;
|
<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>inline()</script><script src="/assets/app.js"></script></head><body></body></html>"#;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() {
|
fn the_static_cdn_argument_resolves_every_hole_the_shell_carries() {
|
||||||
let rendered = render_spa_document(
|
let rendered = render_spa_document(
|
||||||
SHELL_WITH_ENDPOINT_HOLES,
|
SHELL_WITH_ENDPOINT_HOLES,
|
||||||
"reqnonce",
|
|
||||||
"<script>booted</script>",
|
"<script>booted</script>",
|
||||||
"https://cdn.example.test/",
|
"https://cdn.example.test/",
|
||||||
"https://media.example.test",
|
"https://media.example.test",
|
||||||
@@ -751,7 +746,6 @@ mod tests {
|
|||||||
fn the_media_argument_is_resolved_and_weighed_against_the_static_cdn() {
|
fn the_media_argument_is_resolved_and_weighed_against_the_static_cdn() {
|
||||||
let distinct = render_spa_document(
|
let distinct = render_spa_document(
|
||||||
SHELL_WITH_ENDPOINT_HOLES,
|
SHELL_WITH_ENDPOINT_HOLES,
|
||||||
"reqnonce",
|
|
||||||
"<script>booted</script>",
|
"<script>booted</script>",
|
||||||
"https://cdn.example.test",
|
"https://cdn.example.test",
|
||||||
"https://media.example.test/",
|
"https://media.example.test/",
|
||||||
@@ -767,7 +761,6 @@ mod tests {
|
|||||||
|
|
||||||
let shared = render_spa_document(
|
let shared = render_spa_document(
|
||||||
SHELL_WITH_ENDPOINT_HOLES,
|
SHELL_WITH_ENDPOINT_HOLES,
|
||||||
"reqnonce",
|
|
||||||
"<script>booted</script>",
|
"<script>booted</script>",
|
||||||
"https://cdn.example.test",
|
"https://cdn.example.test",
|
||||||
"https://cdn.example.test",
|
"https://cdn.example.test",
|
||||||
@@ -934,21 +927,72 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn nonce_granted_by(response: &Response) -> String {
|
fn policy_of(response: &Response) -> String {
|
||||||
let policy = response
|
response
|
||||||
.headers()
|
.headers()
|
||||||
.get(header::CONTENT_SECURITY_POLICY)
|
.get(header::CONTENT_SECURITY_POLICY)
|
||||||
.expect("the document was served without a content security policy")
|
.expect("the document was served without a content security policy")
|
||||||
.to_str()
|
.to_str()
|
||||||
.unwrap();
|
.unwrap()
|
||||||
let opening = policy
|
.to_owned()
|
||||||
.find("'nonce-")
|
}
|
||||||
.expect("the content security policy granted no nonce at all");
|
|
||||||
let remainder = &policy[opening + "'nonce-".len()..];
|
fn script_hashes_granted_by(policy: &str) -> Vec<String> {
|
||||||
let closing = remainder
|
policy
|
||||||
.find('\'')
|
.split("; ")
|
||||||
.expect("the content security policy left its nonce source unterminated");
|
.find(|directive| directive.starts_with("script-src "))
|
||||||
remainder[..closing].to_owned()
|
.expect("the content security policy has no script-src directive")
|
||||||
|
.split(' ')
|
||||||
|
.filter(|source| source.starts_with("'sha256-"))
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bare_inline_scripts_in(document: &str) -> Vec<&str> {
|
||||||
|
document
|
||||||
|
.split("<script>")
|
||||||
|
.skip(1)
|
||||||
|
.map(|rest| {
|
||||||
|
&rest[..rest
|
||||||
|
.find("</script>")
|
||||||
|
.expect("an inline script in the served document is never closed")]
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sha256_source(text: &str) -> String {
|
||||||
|
use base64::Engine as _;
|
||||||
|
use sha2::Digest as _;
|
||||||
|
format!(
|
||||||
|
"'sha256-{}'",
|
||||||
|
base64::engine::general_purpose::STANDARD.encode(sha2::Sha256::digest(text))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn assert_every_inline_script_is_granted(document: &str, policy: &str) {
|
||||||
|
for tag in document.split("<script").skip(1) {
|
||||||
|
let tag = &tag[..tag.find('>').unwrap()];
|
||||||
|
assert!(
|
||||||
|
tag.is_empty() || tag.contains(" src="),
|
||||||
|
"the served document carries a script tag the test cannot classify: <script{tag}>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let inline = bare_inline_scripts_in(document);
|
||||||
|
assert!(
|
||||||
|
!inline.is_empty(),
|
||||||
|
"the served document carries no inline script at all"
|
||||||
|
);
|
||||||
|
let mut expected: Vec<String> = inline.iter().map(|script| sha256_source(script)).collect();
|
||||||
|
expected.sort();
|
||||||
|
expected.dedup();
|
||||||
|
let mut granted = script_hashes_granted_by(policy);
|
||||||
|
granted.sort();
|
||||||
|
assert_eq!(
|
||||||
|
granted, expected,
|
||||||
|
"the policy must grant exactly the inline scripts the document carries"
|
||||||
|
);
|
||||||
|
assert!(!document.contains("nonce"));
|
||||||
|
assert!(!policy.contains("nonce"));
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn read_document(response: Response) -> String {
|
async fn read_document(response: Response) -> String {
|
||||||
@@ -1059,26 +1103,14 @@ mod tests {
|
|||||||
|
|
||||||
let response = serve_spa_index(&state, &HeaderMap::new()).await;
|
let response = serve_spa_index(&state, &HeaderMap::new()).await;
|
||||||
assert_eq!(response.status(), StatusCode::OK);
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
let granted_nonce = nonce_granted_by(&response);
|
let policy = policy_of(&response);
|
||||||
let served = read_document(response).await;
|
let served = read_document(response).await;
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
!served.contains("{{CSP_NONCE_PLACEHOLDER}}"),
|
served.contains("<script>window.__FLUXER_BOOTSTRAP__"),
|
||||||
"the live branch shipped an unfilled nonce hole"
|
"the live bootstrap is not a bare inline script"
|
||||||
);
|
|
||||||
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_every_inline_script_is_granted(&served, &policy);
|
||||||
assert!(
|
assert!(
|
||||||
!served.contains("{{STATIC_CDN_ENDPOINT}}"),
|
!served.contains("{{STATIC_CDN_ENDPOINT}}"),
|
||||||
"the live branch shipped an unresolved static CDN hole"
|
"the live branch shipped an unresolved static CDN hole"
|
||||||
@@ -1159,9 +1191,11 @@ mod tests {
|
|||||||
.to_str()
|
.to_str()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
cache_control, "no-cache",
|
cache_control, SHARED_SHELL_CACHE_CONTROL,
|
||||||
"the document naming the hashed bundle must be revalidated on every load"
|
"the document naming the hashed bundle must be revalidated on every load and held \
|
||||||
|
by a shared cache for one second at most"
|
||||||
);
|
);
|
||||||
|
assert!(response.headers().get(header::SET_COOKIE).is_none());
|
||||||
assert_ne!(
|
assert_ne!(
|
||||||
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
|
cache_control, LONG_LIVED_ASSET_CACHE_CONTROL,
|
||||||
"a shell cached for a year pins every returning visitor to the deployed-over bundle"
|
"a shell cached for a year pins every returning visitor to the deployed-over bundle"
|
||||||
@@ -1254,6 +1288,89 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn request_from_visitor(ip: &str, country: &str, language: &str) -> HeaderMap {
|
||||||
|
let mut headers = request_from_host("web.fluxer.app");
|
||||||
|
for (name, value) in [
|
||||||
|
("x-forwarded-for", ip),
|
||||||
|
("cf-connecting-ip", ip),
|
||||||
|
("cf-ipcountry", country),
|
||||||
|
("accept-language", language),
|
||||||
|
("cookie", "session=visitor-specific"),
|
||||||
|
] {
|
||||||
|
headers.insert(
|
||||||
|
HeaderName::from_static(name),
|
||||||
|
HeaderValue::from_str(value).unwrap(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
headers
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn every_visitor_to_a_host_gets_a_byte_identical_shell_and_policy() {
|
||||||
|
let mut state = assemble_spa_state(
|
||||||
|
ReleaseChannel::Stable,
|
||||||
|
Some(SHIPPED_APP_SHELL),
|
||||||
|
DISCOVERY_BODY_WITH_WEB_APP_ENDPOINTS,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let mut config = (*state.config).clone();
|
||||||
|
config.same_origin_hosts = vec!["web.fluxer.app".to_owned()];
|
||||||
|
config.trust_client_ip_header = true;
|
||||||
|
state.config = Arc::new(config);
|
||||||
|
|
||||||
|
let stockholm =
|
||||||
|
serve_spa_index(&state, &request_from_visitor("81.234.0.1", "SE", "sv-SE")).await;
|
||||||
|
let sao_paulo =
|
||||||
|
serve_spa_index(&state, &request_from_visitor("177.0.0.1", "BR", "pt-BR")).await;
|
||||||
|
assert_eq!(stockholm.status(), StatusCode::OK);
|
||||||
|
assert_eq!(sao_paulo.status(), StatusCode::OK);
|
||||||
|
|
||||||
|
let stockholm_policy = policy_of(&stockholm);
|
||||||
|
let sao_paulo_policy = policy_of(&sao_paulo);
|
||||||
|
assert_eq!(stockholm_policy, sao_paulo_policy);
|
||||||
|
assert!(stockholm.headers().get(header::SET_COOKIE).is_none());
|
||||||
|
assert!(sao_paulo.headers().get(header::SET_COOKIE).is_none());
|
||||||
|
|
||||||
|
let stockholm_body = read_document(stockholm).await;
|
||||||
|
let sao_paulo_body = read_document(sao_paulo).await;
|
||||||
|
assert_eq!(stockholm_body, sao_paulo_body);
|
||||||
|
assert!(!stockholm_body.contains("geoip"));
|
||||||
|
assert!(!stockholm_body.contains("countryCode"));
|
||||||
|
assert_every_inline_script_is_granted(&stockholm_body, &stockholm_policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn the_shipped_shell_runs_every_inline_script_it_carries_under_its_policy() {
|
||||||
|
let state = spa_state_serving(ReleaseChannel::Stable, Some(SHIPPED_APP_SHELL)).await;
|
||||||
|
|
||||||
|
let response = serve_spa_index(&state, &HeaderMap::new()).await;
|
||||||
|
assert_eq!(response.status(), StatusCode::OK);
|
||||||
|
let policy = policy_of(&response);
|
||||||
|
let served = read_document(response).await;
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
bare_inline_scripts_in(&served).len(),
|
||||||
|
bare_inline_scripts_in(SHIPPED_APP_SHELL).len() + 1,
|
||||||
|
"every inline script of fluxer_app/index.html plus the bootstrap must reach the document"
|
||||||
|
);
|
||||||
|
assert_every_inline_script_is_granted(&served, &policy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn an_index_upstream_document_is_granted_after_its_dev_cache_buster() {
|
||||||
|
let index_upstream_url = spawn_local_origin(SHIPPED_APP_SHELL, "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);
|
||||||
|
let policy = policy_of(&response);
|
||||||
|
let served = read_document(response).await;
|
||||||
|
|
||||||
|
assert_every_inline_script_is_granted(&served, &policy);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn font_mime_types_are_cors_enabled() {
|
fn font_mime_types_are_cors_enabled() {
|
||||||
assert!(is_font_mime("font/woff2"));
|
assert!(is_font_mime("font/woff2"));
|
||||||
|
|||||||
@@ -1570,7 +1570,7 @@ Defaults to the crate version. The reported build of `admin` and `app-proxy`. `B
|
|||||||
|
|
||||||
## Content Security Policy
|
## Content Security Policy
|
||||||
|
|
||||||
`app-proxy` builds a per-request nonce-based policy for the client HTML and the assets it serves. Each variable appends sources to one directive on top of the built-in ones. All are empty by default, and Compose forwards every one.
|
`app-proxy` builds the policy for the client HTML and the assets it serves. The client HTML policy allows each inline script by its SHA-256 hash, so every visitor to a host gets the same document and header. Each variable appends sources to one directive on top of the built-in ones. All are empty by default, and Compose forwards every one.
|
||||||
|
|
||||||
Every name below goes in `.env`. `app-proxy` reads its environment at container start, so a change takes effect on `docker compose up -d app-proxy`. A `docker compose restart app-proxy` does not apply it.
|
Every name below goes in `.env`. `app-proxy` reads its environment at container start, so a change takes effect on `docker compose up -d app-proxy`. A `docker compose restart app-proxy` does not apply it.
|
||||||
|
|
||||||
|
|||||||
@@ -395,6 +395,7 @@ Forward every path and query string unchanged. The edge handles routing:
|
|||||||
| `/.well-known/apple-app-site-association`, `/apple-app-site-association` | Apple app association |
|
| `/.well-known/apple-app-site-association`, `/apple-app-site-association` | Apple app association |
|
||||||
| `/.well-known/assetlinks.json` | Android app association |
|
| `/.well-known/assetlinks.json` | Android app association |
|
||||||
| `/version.json` | Client version metadata |
|
| `/version.json` | Client version metadata |
|
||||||
|
| `/_geoip` | Client location lookup |
|
||||||
|
|
||||||
All other paths serve the web app. The admin path follows `FLUXER_ADMIN_BASE_PATH`, `/admin` by default.
|
All other paths serve the web app. The admin path follows `FLUXER_ADMIN_BASE_PATH`, `/admin` by default.
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user