feat(server): derive GitHub OAuth, CSP and install URLs from the configured host

This commit is contained in:
Dávid Balatoni 2026-10-25 01:04:40 +02:00
parent b981b6fea0
commit 8eaaa1f2c2
No known key found for this signature in database
9 changed files with 229 additions and 66 deletions

View file

@ -224,6 +224,7 @@ async fn run_install_mode(bootstrap: InstallBootstrap, printer: Printer) -> Resu
&bootstrap.storage_dir,
&bootstrap.config_path,
)
.with_github_host(crate::user_config::github_host()?)
.with_finish_hook(persist_install_dev_token_hook());
install::serve_install_command(bootstrap.bind_request, state, move |bind| {
announce_install_mode(bind, &token, styles, printer);

View file

@ -4,6 +4,7 @@
)]
use fabro_http::Url;
use fabro_types::GitHubHost;
#[doc(hidden)]
#[derive(Debug, Clone)]
@ -13,11 +14,12 @@ pub struct GithubEndpoints {
}
impl GithubEndpoints {
pub fn production_defaults() -> Self {
pub fn from_host(host: &GitHubHost) -> Self {
Self {
oauth_base: Url::parse("https://github.com/").expect("github oauth base should parse"),
api_base: Url::parse("https://api.github.com/")
.expect("github api base should parse"),
oauth_base: Url::parse(&format!("{}/", host.web_url()))
.expect("GitHubHost web_url should parse as a URL"),
api_base: Url::parse(&format!("{}/", host.api_url()))
.expect("GitHubHost api_url should parse as a URL"),
}
}
@ -32,16 +34,42 @@ impl GithubEndpoints {
#[cfg(test)]
mod tests {
use fabro_types::GitHubHost;
use super::GithubEndpoints;
#[test]
fn production_defaults_match_current_github_urls() {
let endpoints = GithubEndpoints::production_defaults();
fn default_host_matches_current_github_urls() {
let endpoints = GithubEndpoints::from_host(&GitHubHost::default());
assert_eq!(endpoints.oauth_base.as_str(), "https://github.com/");
assert_eq!(endpoints.api_base.as_str(), "https://api.github.com/");
}
#[test]
fn enterprise_host_uses_its_web_origin_and_api_url() {
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
let endpoints = GithubEndpoints::from_host(&host);
assert_eq!(endpoints.oauth_base.as_str(), "https://ghe.example.com/");
assert_eq!(
endpoints.api_base.as_str(),
"https://ghe.example.com/api/v3/"
);
assert_eq!(
endpoints
.oauth_base
.join("login/oauth/authorize")
.unwrap()
.as_str(),
"https://ghe.example.com/login/oauth/authorize"
);
assert_eq!(
endpoints.api_base.join("user").unwrap().as_str(),
"https://ghe.example.com/api/v3/user"
);
}
#[test]
fn with_bases_uses_custom_urls() {
let endpoints = GithubEndpoints::with_bases(

View file

@ -4,24 +4,17 @@
//! `index.html` so any inline `<script>` hashes don't drift from the
//! template. Third-party sources are enumerated explicitly.
use std::sync::OnceLock;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD;
use fabro_types::GitHubHost;
use sha2::{Digest, Sha256};
static POLICY: OnceLock<String> = OnceLock::new();
pub(crate) const INSTALL_MODE_SCRIPT_BODY: &str = "window.__FABRO_MODE__ = \"install\";";
pub fn policy() -> &'static str {
POLICY.get_or_init(build_policy)
}
fn build_policy() -> String {
pub fn policy(host: &GitHubHost) -> String {
let mut script_hashes = inline_script_hashes_from_embedded_index();
script_hashes.push(script_hash(INSTALL_MODE_SCRIPT_BODY));
build_policy_with_hashes(&script_hashes)
build_policy_with_hashes(&script_hashes, host)
}
fn inline_script_hashes_from_embedded_index() -> Vec<String> {
@ -70,7 +63,18 @@ fn script_hash(script: &str) -> String {
format!("sha256-{}", STANDARD.encode(hash))
}
fn build_policy_with_hashes(script_hashes: &[String]) -> String {
/// Avatars of a GitHub Enterprise host are served from the host itself
/// (`https://avatars.<host>`) or from its web origin, not from
/// githubusercontent.com.
fn github_img_sources(host: &GitHubHost) -> String {
if host.is_github_com() {
"https://avatars.githubusercontent.com".to_string()
} else {
format!("{} https://avatars.{}", host.web_url(), host.host())
}
}
fn build_policy_with_hashes(script_hashes: &[String], host: &GitHubHost) -> String {
let inline_script_sources = if script_hashes.is_empty() {
String::new()
} else {
@ -92,7 +96,7 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
// terminal WebSocket working across browsers that do not treat `'self'`
// as matching WebSocket schemes for connect-src. `frame-src https:`
// allows signed sandbox VNC preview iframes from dynamic Daytona preview
// hosts. `https://github.com` in form-action allows the install-mode
// hosts. The GitHub web origin in form-action allows the install-mode
// GitHub App manifest POST handoff. Loopback resume targets keep CLI
// OAuth working when the browser-visible origin and canonical origin use
// different loopback hostnames on a dynamically chosen port.
@ -101,15 +105,17 @@ fn build_policy_with_hashes(script_hashes: &[String]) -> String {
script-src 'self'{inline_script_sources} 'wasm-unsafe-eval'; \
style-src 'self' https://fonts.googleapis.com 'unsafe-inline'; \
font-src 'self' https://fonts.gstatic.com; \
img-src 'self' data: blob: https://avatars.githubusercontent.com; \
img-src 'self' data: blob: {img_sources}; \
connect-src 'self' ws: wss:; \
worker-src 'self' blob:; \
frame-src 'self' https:; \
manifest-src 'self'; \
frame-ancestors 'none'; \
base-uri 'self'; \
form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
object-src 'none'"
form-action 'self' {web_url} http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume; \
object-src 'none'",
img_sources = github_img_sources(host),
web_url = host.web_url(),
)
}
@ -151,7 +157,7 @@ mod tests {
#[test]
fn policy_allows_install_mode_inline_bootstrap() {
let policy = build_policy();
let policy = policy(&GitHubHost::default());
let expected_hash = script_hash(INSTALL_MODE_SCRIPT_BODY);
assert!(
policy.contains(&format!("'{expected_hash}'")),
@ -161,7 +167,7 @@ mod tests {
#[test]
fn policy_allows_cli_loopback_resume_form_posts() {
let policy = build_policy_with_hashes(&[]);
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
assert!(
policy.contains("form-action 'self' https://github.com http://127.0.0.1:*/auth/cli/resume http://localhost:*/auth/cli/resume"),
"CLI auth can submit the resume form to the canonical loopback origin on a dynamic port: {policy}"
@ -170,7 +176,7 @@ mod tests {
#[test]
fn policy_is_constructed_with_expected_directives() {
let policy = build_policy_with_hashes(&["sha256-abc".to_string()]);
let policy = build_policy_with_hashes(&["sha256-abc".to_string()], &GitHubHost::default());
assert!(policy.contains("default-src 'self'"));
assert!(
policy.contains("script-src 'self' 'sha256-abc' 'wasm-unsafe-eval'"),
@ -191,9 +197,31 @@ mod tests {
assert!(policy.contains("object-src 'none'"));
}
#[test]
fn github_com_policy_is_unchanged() {
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
assert!(policy.contains(
"img-src 'self' data: blob: https://avatars.githubusercontent.com; connect-src"
));
}
#[test]
fn enterprise_policy_uses_the_configured_web_origin() {
let host = GitHubHost::new("https://ghe.example.com", None).unwrap();
let policy = build_policy_with_hashes(&[], &host);
assert!(policy.contains(
"img-src 'self' data: blob: https://ghe.example.com https://avatars.ghe.example.com; connect-src"
));
assert!(policy.contains(
"form-action 'self' https://ghe.example.com http://127.0.0.1:*/auth/cli/resume"
));
assert!(!policy.contains("https://github.com"));
assert!(!policy.contains("githubusercontent.com"));
}
#[test]
fn policy_is_valid_without_inline_scripts() {
let policy = build_policy_with_hashes(&[]);
let policy = build_policy_with_hashes(&[], &GitHubHost::default());
assert!(
policy.contains("script-src 'self' 'wasm-unsafe-eval'"),
"empty hash list should not produce a stray space: {policy}"
@ -204,7 +232,7 @@ mod tests {
fn embedded_spa_index_builds_a_policy() {
// Guards against the embedded asset going missing or becoming
// unreadable in a way that would leave the CSP header blank.
let policy = build_policy();
let policy = policy(&GitHubHost::default());
assert!(
policy.contains("script-src 'self'"),
"embedded SPA policy should contain a script-src directive"

View file

@ -30,7 +30,7 @@ use fabro_static::EnvVars;
use fabro_store::ArtifactStore;
use fabro_types::settings::server::ObjectStoreSettings;
use fabro_types::settings::{is_wildcard_host, validate_public_url_with_label};
use fabro_types::{SandboxProviderKind, ServerSettings};
use fabro_types::{GitHubHost, SandboxProviderKind, ServerSettings};
use fabro_util::version::FABRO_VERSION;
use fabro_util::{Home, session_secret};
use fabro_vault::SecretType as VaultSecretType;
@ -52,7 +52,7 @@ use crate::sandbox_access::{
};
use crate::serve::{self, DEFAULT_TCP_PORT};
use crate::server_secrets::{ServerSecrets, process_env_snapshot};
use crate::{security_headers, server, static_files};
use crate::{csp, security_headers, server, static_files};
#[derive(Clone)]
pub struct InstallAppState {
@ -65,6 +65,7 @@ pub struct InstallAppState {
first_operator: Arc<Mutex<Option<InstallOperatorFingerprint>>>,
finish_in_progress: Arc<AtomicBool>,
upstreams: InstallUpstreamConfig,
github_host: GitHubHost,
static_asset_root: Option<Arc<Path>>,
on_finish: Option<Arc<dyn Fn() + Send + Sync>>,
finish_hook: Option<InstallFinishHook>,
@ -91,7 +92,6 @@ struct InstallOperatorFingerprint {
remote_ip: Option<String>,
}
pub const DEFAULT_INSTALL_GITHUB_API_BASE_URL: &str = "https://api.github.com";
const DEFAULT_INSTALL_TCP_LISTEN_ADDRESS: &str = "127.0.0.1:32276";
const REDACTED_SECRET_VALUE: &str = "[REDACTED]";
const VALIDATION_TIMEOUT: Duration = Duration::from_secs(20);
@ -115,6 +115,7 @@ impl InstallAppState {
first_operator: Arc::new(Mutex::new(None)),
finish_in_progress: Arc::new(AtomicBool::new(false)),
upstreams: InstallUpstreamConfig::default(),
github_host: GitHubHost::default(),
static_asset_root: None,
on_finish: None,
finish_hook: None,
@ -157,6 +158,7 @@ impl InstallAppState {
first_operator: Arc::new(Mutex::new(None)),
finish_in_progress: Arc::new(AtomicBool::new(false)),
upstreams: InstallUpstreamConfig::default(),
github_host: GitHubHost::default(),
static_asset_root: None,
on_finish: None,
finish_hook: None,
@ -203,6 +205,12 @@ impl InstallAppState {
self
}
#[must_use]
pub fn with_github_host(mut self, host: GitHubHost) -> Self {
self.github_host = host;
self
}
#[must_use]
pub fn with_github_api_base_url(mut self, base_url: impl Into<String>) -> Self {
self.upstreams.github_api_base_url = Some(base_url.into());
@ -566,11 +574,12 @@ enum GitHubAppOwner {
}
impl GitHubAppOwner {
fn manifest_form_action(&self) -> String {
fn manifest_form_action(&self, host: &GitHubHost) -> String {
let web_url = host.web_url();
match self {
Self::Personal => "https://github.com/settings/apps/new".to_string(),
Self::Personal => format!("{web_url}/settings/apps/new"),
Self::Organization(org) => {
format!("https://github.com/organizations/{org}/settings/apps/new")
format!("{web_url}/organizations/{org}/settings/apps/new")
}
}
}
@ -602,6 +611,7 @@ impl TryFrom<GithubAppOwnerInput> for GitHubAppOwner {
}
pub fn build_install_router(state: InstallAppState) -> Router {
let csp_policy = Arc::new(csp::policy(&state.github_host));
let static_asset_root = state.static_asset_root.clone();
Router::new()
@ -670,7 +680,10 @@ pub fn build_install_router(state: InstallAppState) -> Router {
// router; a first-run setup over a slow link needs compression just
// as much.
.layer(server::compression_layer())
.layer(middleware::from_fn(security_headers::layer))
.layer(middleware::from_fn_with_state(
csp_policy,
security_headers::layer,
))
}
struct InstallFinishGuard {
@ -1422,7 +1435,7 @@ async fn post_install_github_app_manifest(
Json(serde_json::json!({
"manifest": manifest,
"github_form_action": owner.manifest_form_action(),
"github_form_action": owner.manifest_form_action(&state.github_host),
"state": state_token,
}))
.into_response()
@ -2178,7 +2191,7 @@ async fn validate_github_token(state: &InstallAppState, token: &str) -> anyhow::
.upstreams
.github_api_base_url
.clone()
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
.unwrap_or_else(|| state.github_host.api_url().to_string());
let endpoint = install_upstream_endpoint(&base_url, &["user"])?;
let client = install_http_client_for_url(&base_url)?;
let response = client
@ -2210,7 +2223,7 @@ async fn exchange_github_app_manifest_code(
.upstreams
.github_api_base_url
.clone()
.unwrap_or_else(|| DEFAULT_INSTALL_GITHUB_API_BASE_URL.to_string());
.unwrap_or_else(|| state.github_host.api_url().to_string());
let endpoint = install_upstream_endpoint(&base_url, &["app-manifests", code, "conversions"])?;
let client = install_http_client_for_url(&base_url)?;
let response = client
@ -2319,15 +2332,15 @@ mod tests {
use serde_json::json;
use super::{
DEFAULT_INSTALL_GITHUB_API_BASE_URL, GitHubAppOwner, GithubAppInstall, GithubInstallState,
InstallAppState, InstallAwsCredentialPair, InstallFinishGuard,
InstallObjectStoreCredentialMode, InstallObjectStoreInput, InstallObjectStoreProvider,
InstallObjectStoreState, InstallSandboxProviderState, InstallSandboxState,
InstallTokenQuery, LlmProvidersInput, PendingInstall, ServerConfigInput, ServerSecrets,
build_github_app_manifest, classify_object_store_validation_error, detect_canonical_url,
install_catalog_provider, install_object_store_lookup, lock_unpoisoned,
post_install_finish, provider_base_url_override, resolve_install_object_store_state,
token_is_valid, write_artifact_store_metadata,
GitHubAppOwner, GitHubHost, GithubAppInstall, GithubInstallState, InstallAppState,
InstallAwsCredentialPair, InstallFinishGuard, InstallObjectStoreCredentialMode,
InstallObjectStoreInput, InstallObjectStoreProvider, InstallObjectStoreState,
InstallSandboxProviderState, InstallSandboxState, InstallTokenQuery, LlmProvidersInput,
PendingInstall, ServerConfigInput, ServerSecrets, build_github_app_manifest,
classify_object_store_validation_error, detect_canonical_url, install_catalog_provider,
install_object_store_lookup, lock_unpoisoned, post_install_finish,
provider_base_url_override, resolve_install_object_store_state, token_is_valid,
write_artifact_store_metadata,
};
#[test]
@ -2529,10 +2542,38 @@ mod tests {
}
#[test]
fn install_github_requests_default_to_fixed_github_api_base_url() {
fn install_github_requests_default_to_the_host_api_url() {
let state = InstallAppState::for_test("expected");
assert_eq!(state.github_host.api_url(), "https://api.github.com");
let state =
state.with_github_host(GitHubHost::new("https://ghe.example.com", None).unwrap());
assert_eq!(
DEFAULT_INSTALL_GITHUB_API_BASE_URL,
"https://api.github.com"
state.github_host.api_url(),
"https://ghe.example.com/api/v3"
);
}
#[test]
fn manifest_form_action_targets_the_host_web_origin() {
let github_com = GitHubHost::default();
let ghes = GitHubHost::new("https://ghe.example.com", None).unwrap();
let org = GitHubAppOwner::Organization("acme".to_string());
assert_eq!(
GitHubAppOwner::Personal.manifest_form_action(&github_com),
"https://github.com/settings/apps/new"
);
assert_eq!(
org.manifest_form_action(&github_com),
"https://github.com/organizations/acme/settings/apps/new"
);
assert_eq!(
GitHubAppOwner::Personal.manifest_form_action(&ghes),
"https://ghe.example.com/settings/apps/new"
);
assert_eq!(
org.manifest_form_action(&ghes),
"https://ghe.example.com/organizations/acme/settings/apps/new"
);
}

View file

@ -4,26 +4,26 @@
//! can still override any header by setting their own value first — the
//! defaults here only fill in what's missing.
use axum::extract::Request;
use std::sync::Arc;
use axum::extract::{Request, State};
use axum::http::{HeaderMap, HeaderName, HeaderValue, header};
use axum::middleware::Next;
use axum::response::Response;
use crate::csp;
pub async fn layer(req: Request, next: Next) -> Response {
pub async fn layer(State(csp_policy): State<Arc<String>>, req: Request, next: Next) -> Response {
let is_https = request_is_https(&req);
let mut response = next.run(req).await;
apply_defaults(response.headers_mut(), is_https);
apply_csp(response.headers_mut());
apply_csp(response.headers_mut(), &csp_policy);
response
}
fn apply_csp(headers: &mut HeaderMap) {
fn apply_csp(headers: &mut HeaderMap, policy: &str) {
// Report-only while we tune the policy: browsers evaluate violations and log
// them to the console without blocking any resources. Switch back to the
// enforcing `content-security-policy` header once the policy is clean.
if let Ok(value) = HeaderValue::from_str(csp::policy()) {
if let Ok(value) = HeaderValue::from_str(policy) {
headers
.entry(HeaderName::from_static(
"content-security-policy-report-only",
@ -202,7 +202,7 @@ mod tests {
#[test]
fn csp_is_report_only_not_enforced() {
let mut headers = HeaderMap::new();
apply_csp(&mut headers);
apply_csp(&mut headers, "default-src 'self'");
assert!(headers.contains_key("content-security-policy-report-only"));
assert!(!headers.contains_key("content-security-policy"));
}

View file

@ -159,7 +159,7 @@ use crate::worker_control::{
use crate::worker_runtime::{LocalWorkerRuntime, WorkerLaunchSpec, WorkerRef, WorkerRuntime};
use crate::worker_token::{WorkerScopeSet, WorkerTokenKeys, issue_worker_token_with_scopes};
use crate::{
canonical_host, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
canonical_host, csp, demo, diagnostics, run_manifest, security_headers, static_files, web_auth,
};
mod automation_scheduler;
@ -1924,8 +1924,9 @@ pub fn build_router_with_options(
} = options;
let translation_state = Arc::clone(&state);
let state_for_canonical_host = Arc::clone(&state);
let github_endpoints =
github_endpoints.unwrap_or_else(|| Arc::new(GithubEndpoints::production_defaults()));
let github_endpoints = github_endpoints
.unwrap_or_else(|| Arc::new(GithubEndpoints::from_host(&state.github_host)));
let csp_policy = Arc::new(csp::policy(&state.github_host));
let webhook_secret = state.github_webhook_secret.clone();
let principal_layer = middleware::from_fn_with_state(Arc::clone(&state), principal_middleware);
let api_common = if web_enabled {
@ -2032,7 +2033,10 @@ pub fn build_router_with_options(
},
canonical_host::redirect_middleware,
))
.layer(middleware::from_fn(security_headers::layer))
.layer(middleware::from_fn_with_state(
csp_policy,
security_headers::layer,
))
.layer(middleware::from_fn(http_log_middleware))
.layer(middleware::from_fn(request_id::layer))
}

View file

@ -496,7 +496,10 @@ async fn get_github_repo(
let install_url = creds
.installation_url(&state.github_host, &owner)
.unwrap_or_else(|| {
format!("https://github.com/organizations/{owner}/settings/installations")
format!(
"{}/organizations/{owner}/settings/installations",
state.github_host.web_url()
)
});
let client = match state.http_client() {

View file

@ -6304,6 +6304,60 @@ slug = "fabro"
assert!(location.starts_with("https://github.com/login/oauth/authorize?"));
}
#[tokio::test]
async fn auth_login_github_redirects_to_the_configured_enterprise_host() {
let source = r#"
_version = 1
[server.auth]
methods = ["github"]
[server.web]
enabled = true
url = "http://localhost:3000"
[server.auth.github]
allowed_usernames = ["octocat"]
[server.integrations.github]
app_id = "123"
client_id = "Iv1.testclient"
slug = "fabro"
web_url = "https://ghe.example.com"
"#;
let app = build_router(
test_app_state_with_session_key(
server_settings_from_toml(source),
manifest_run_defaults_from_toml(source),
Some("github-redirect-test-key-0123456789"),
),
AuthMode::Enabled(ConfiguredAuth {
methods: vec![ServerAuthMethod::Github],
dev_token: None,
jwt_key: None,
jwt_issuer: None,
}),
);
let response = app
.oneshot(
Request::builder()
.uri("/auth/login/github")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let response = checked_response!(response, StatusCode::SEE_OTHER).await;
let location = response
.headers()
.get(axum::http::header::LOCATION)
.and_then(|value| value.to_str().ok())
.unwrap();
assert!(location.starts_with("https://ghe.example.com/login/oauth/authorize?"));
}
#[tokio::test]
async fn logout_redirects_to_login_page() {
let app = test_app_with();

View file

@ -762,12 +762,12 @@ async fn callback_github(
v: 2,
login: profile.login.clone(),
auth_method: AuthMethod::Github,
identity: IdpIdentity::new("https://github.com", profile.id.to_string())
identity: IdpIdentity::new(state.github_host.web_url(), profile.id.to_string())
.expect("GitHub profile id should produce a valid identity"),
name: profile.name.unwrap_or_else(|| profile.login.clone()),
email: primary_email,
avatar_url: profile.avatar_url,
user_url: format!("https://github.com/{}", profile.login),
user_url: format!("{}/{}", state.github_host.web_url(), profile.login),
iat: now.timestamp(),
exp: (now + chrono::Duration::days(30)).timestamp(),
};
@ -997,7 +997,7 @@ mod tests {
use fabro_config::{RunLayer, ServerSettingsBuilder};
use fabro_static::EnvVars;
use fabro_types::settings::server::ServerAuthMethod;
use fabro_types::{AuthMethod, IdpIdentity, Principal};
use fabro_types::{AuthMethod, GitHubHost, IdpIdentity, Principal};
use fabro_vault::SecretType;
use serde_json::json;
use tower::ServiceExt;
@ -1109,7 +1109,9 @@ client_id = "github-client-id"
translation_state,
crate::auth::auth_translation_middleware,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
.layer(Extension(Arc::new(GithubEndpoints::from_host(
&GitHubHost::default(),
))))
.layer(Extension(auth_mode))
.with_state(state)
}
@ -1130,7 +1132,9 @@ client_id = "github-client-id"
Arc::clone(&captured),
crate::test_support::capture_auth_context,
))
.layer(Extension(Arc::new(GithubEndpoints::production_defaults())))
.layer(Extension(Arc::new(GithubEndpoints::from_host(
&GitHubHost::default(),
))))
.layer(Extension(auth_mode))
.with_state(state);
(app, captured)