Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/config/src/loader/config_io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -375,6 +375,7 @@ pub(super) fn apply_env_overrides_with(
"MOLTIS_TAILSCALE",
"MOLTIS_WEBAUTHN_RP_ID",
"MOLTIS_WEBAUTHN_ORIGIN",
"MOLTIS_EXTERNAL_URL",
];

let mut root: Value = match serde_json::to_value(&config) {
Expand Down
15 changes: 15 additions & 0 deletions crates/config/src/loader/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,21 @@ fn apply_env_overrides_ignores_excluded() {
assert!(!config.auth.disabled);
}

#[test]
fn apply_env_overrides_ignores_external_url() {
// MOLTIS_EXTERNAL_URL is handled by effective_external_url(), not by
// the generic env override mechanism.
let vars = vec![(
"MOLTIS_EXTERNAL_URL".into(),
"https://test.example.com".into(),
)];
let config = apply_env_overrides_with(MoltisConfig::default(), vars.into_iter());
assert!(
config.server.external_url.is_none(),
"MOLTIS_EXTERNAL_URL should be excluded from generic env overrides"
);
}

#[test]
fn apply_env_overrides_multiple() {
let vars = vec![
Expand Down
20 changes: 20 additions & 0 deletions crates/config/src/schema/system.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ pub struct ServerConfig {
/// this field and cannot be changed from the web UI config editor.
#[serde(default = "default_terminal_enabled")]
pub terminal_enabled: bool,
/// Public URL when running behind a reverse proxy (e.g.
/// `https://moltis.example.com`). Used to derive the WebAuthn RP ID
/// and origin so passkey auth works with the proxy's public hostname.
///
/// The `MOLTIS_EXTERNAL_URL` environment variable takes precedence
/// over this field.
pub external_url: Option<String>,
}

fn default_log_buffer_size() -> usize {
Expand All @@ -71,6 +78,7 @@ impl Default for ServerConfig {
db_pool_max_connections: default_db_pool_max_connections(),
shiki_cdn_url: None,
terminal_enabled: default_terminal_enabled(),
external_url: None,
}
}
}
Expand All @@ -88,6 +96,18 @@ impl ServerConfig {
}
self.terminal_enabled
}

/// Returns the effective external URL, checking `MOLTIS_EXTERNAL_URL`
/// env var first, then falling back to the config field. Trailing
/// slashes are stripped so the result can be used directly as a
/// WebAuthn origin.
pub fn effective_external_url(&self) -> Option<String> {
let raw = std::env::var("MOLTIS_EXTERNAL_URL")
.ok()
.filter(|v| !v.is_empty())
.or_else(|| self.external_url.clone())?;
Some(raw.trim_end_matches('/').to_string())
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}

/// ngrok public HTTPS tunnel configuration.
Expand Down
43 changes: 43 additions & 0 deletions crates/config/src/schema/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -769,3 +769,46 @@ base_url = "http://127.0.0.1:8001/v1"
Some("http://127.0.0.1:8001/v1")
);
}

#[test]
fn external_url_defaults_to_none() {
let cfg: MoltisConfig = toml::from_str("").unwrap();
assert!(cfg.server.external_url.is_none());
}

#[test]
fn external_url_parses_from_toml() {
let cfg: MoltisConfig =
toml::from_str("[server]\nexternal_url = \"https://moltis.example.com\"\n").unwrap();
assert_eq!(
cfg.server.external_url.as_deref(),
Some("https://moltis.example.com")
);
}

#[test]
fn effective_external_url_returns_config_value() {
let cfg: MoltisConfig =
toml::from_str("[server]\nexternal_url = \"https://from-config.example.com\"\n").unwrap();
// When MOLTIS_EXTERNAL_URL is not set, the config value is returned.
// (We cannot test the env-var override here because set_var is unsafe.)
let effective = cfg.server.effective_external_url();
assert!(effective.is_some());
assert_eq!(effective.unwrap(), "https://from-config.example.com");
}

#[test]
fn effective_external_url_strips_trailing_slash() {
let cfg: MoltisConfig =
toml::from_str("[server]\nexternal_url = \"https://example.com/\"\n").unwrap();
assert_eq!(
cfg.server.effective_external_url().as_deref(),
Some("https://example.com")
);
}

#[test]
fn effective_external_url_returns_none_when_unset() {
let cfg: MoltisConfig = toml::from_str("").unwrap();
assert!(cfg.server.effective_external_url().is_none());
}
3 changes: 3 additions & 0 deletions crates/config/src/template.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ terminal_enabled = true # Enable interactive host terminal in
# For hard lockdown, set MOLTIS_TERMINAL_DISABLED=1 (env var
# takes precedence and cannot be changed from the web UI).
update_releases_url = "https://www.moltis.org/releases.json" # Releases manifest URL for update checks (override to use a custom URL)
# external_url = "https://moltis.example.com" # Public URL when behind a reverse proxy.
# Used for WebAuthn passkey origins.
# Env var MOLTIS_EXTERNAL_URL takes precedence.

# ══════════════════════════════════════════════════════════════════════════════
# UPSTREAM PROXY
Expand Down
1 change: 1 addition & 0 deletions crates/config/src/validate/schema_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,7 @@ pub(super) fn build_schema_map() -> KnownKeys {
("db_pool_max_connections", Leaf),
("shiki_cdn_url", Leaf),
("terminal_enabled", Leaf),
("external_url", Leaf),
])),
),
("providers", MapWithFields {
Expand Down
23 changes: 23 additions & 0 deletions crates/config/src/validate/semantic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,29 @@ pub(super) fn check_semantic_warnings(config: &MoltisConfig, diagnostics: &mut V
}
}

// server.external_url: must use http:// or https:// scheme.
if let Some(ref url) = config.server.external_url {
if !url.starts_with("http://") && !url.starts_with("https://") {
let scheme = url.split("://").next().unwrap_or("<unknown>");
diagnostics.push(Diagnostic {
severity: Severity::Error,
category: "invalid-value",
path: "server.external_url".into(),
message: format!(
"server.external_url must use http:// or https:// scheme (got \"{scheme}://\")"
),
});
}
if url.ends_with('/') {
diagnostics.push(Diagnostic {
severity: Severity::Warning,
category: "invalid-value",
path: "server.external_url".into(),
message: "server.external_url has a trailing slash; WebAuthn origins must not end with '/' (it will be stripped at runtime)".into(),
});
}
}

// upstream_proxy: must be a valid URL with a supported scheme.
if let Some(ref proxy) = config.upstream_proxy {
let url = proxy.expose_secret();
Expand Down
17 changes: 17 additions & 0 deletions crates/config/src/validate/tests/common.rs
Original file line number Diff line number Diff line change
Expand Up @@ -248,3 +248,20 @@ terminal_enabled = false
"terminal_enabled should be a known field, got: {unknown:?}"
);
}

#[test]
fn server_external_url_is_known_field() {
let toml = r#"
[server]
external_url = "https://moltis.example.com"
"#;
let result = validate_toml_str(toml);
let unknown = result
.diagnostics
.iter()
.find(|d| d.category == "unknown-field" && d.path.contains("external_url"));
assert!(
unknown.is_none(),
"external_url should be a known field, got: {unknown:?}"
);
}
74 changes: 74 additions & 0 deletions crates/config/src/validate/tests/security.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,80 @@ domain = "team-gateway.ngrok.app"
);
}

#[test]
fn external_url_bad_scheme_is_error() {
let toml = r#"
[server]
external_url = "ftp://moltis.example.com"
"#;
let result = validate_toml_str(toml);
let error = result.diagnostics.iter().find(|d| {
d.severity == Severity::Error
&& d.path == "server.external_url"
&& d.message.contains("http://")
});
assert!(
error.is_some(),
"expected error for bad scheme, got: {:?}",
result.diagnostics
);
}

#[test]
fn external_url_trailing_slash_is_warning() {
let toml = r#"
[server]
external_url = "https://moltis.example.com/"
"#;
let result = validate_toml_str(toml);
let warning = result.diagnostics.iter().find(|d| {
d.severity == Severity::Warning
&& d.path == "server.external_url"
&& d.message.contains("trailing slash")
});
assert!(
warning.is_some(),
"expected warning for trailing slash, got: {:?}",
result.diagnostics
);
}

#[test]
fn external_url_valid_https_no_diagnostics() {
let toml = r#"
[server]
external_url = "https://moltis.example.com"
"#;
let result = validate_toml_str(toml);
let issues: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.path == "server.external_url")
.collect();
assert!(
issues.is_empty(),
"valid https external_url should produce no diagnostics, got: {issues:?}"
);
}

#[test]
fn external_url_valid_http_no_diagnostics() {
let toml = r#"
[server]
external_url = "http://moltis.local:8080"
"#;
let result = validate_toml_str(toml);
let issues: Vec<_> = result
.diagnostics
.iter()
.filter(|d| d.path == "server.external_url")
.collect();
assert!(
issues.is_empty(),
"valid http external_url should produce no diagnostics, got: {issues:?}"
);
}

#[test]
fn tls_disabled_non_localhost_warned() {
let toml = r#"
Expand Down
42 changes: 32 additions & 10 deletions crates/gateway/src/server/prepare_core/post_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,16 +134,38 @@ async fn build_webauthn_registry(
"http"
};

let explicit_rp_id = std::env::var("MOLTIS_WEBAUTHN_RP_ID")
.or_else(|_| std::env::var("APP_DOMAIN"))
.or_else(|_| std::env::var("RENDER_EXTERNAL_HOSTNAME"))
.or_else(|_| std::env::var("FLY_APP_NAME").map(|name| format!("{name}.fly.dev")))
.or_else(|_| std::env::var("RAILWAY_PUBLIC_DOMAIN"))
.ok();
let explicit_origin = std::env::var("MOLTIS_WEBAUTHN_ORIGIN")
.or_else(|_| std::env::var("APP_URL"))
.or_else(|_| std::env::var("RENDER_EXTERNAL_URL"))
.ok();
// Derive RP ID and origin from server.external_url / MOLTIS_EXTERNAL_URL
// when available, before falling back to fine-grained env vars.
let (external_rp_id, external_origin) =
if let Some(ref ext_url) = config.server.effective_external_url() {
match url::Url::parse(ext_url) {
Ok(parsed) => {
let host = parsed.host_str().unwrap_or_default().to_string();
(Some(host), Some(ext_url.clone()))
},
Err(e) => {
warn!("invalid server.external_url '{ext_url}': {e}");
(None, None)
},
}
} else {
(None, None)
};
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Outdated

let explicit_rp_id = external_rp_id
.or_else(|| std::env::var("MOLTIS_WEBAUTHN_RP_ID").ok())
.or_else(|| std::env::var("APP_DOMAIN").ok())
.or_else(|| std::env::var("RENDER_EXTERNAL_HOSTNAME").ok())
.or_else(|| {
std::env::var("FLY_APP_NAME")
.ok()
.map(|name| format!("{name}.fly.dev"))
})
.or_else(|| std::env::var("RAILWAY_PUBLIC_DOMAIN").ok());
let explicit_origin = external_origin
.or_else(|| std::env::var("MOLTIS_WEBAUTHN_ORIGIN").ok())
.or_else(|| std::env::var("APP_URL").ok())
.or_else(|| std::env::var("RENDER_EXTERNAL_URL").ok());

let mut wa_registry = crate::auth_webauthn::WebAuthnRegistry::new();
let mut any_ok = false;
Expand Down
23 changes: 20 additions & 3 deletions docs/src/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -499,20 +499,37 @@ server process. In practice:
- If a proxy rewrites `Host` and does not preserve browser host context,
passkey routes can fail with "no passkey config for this hostname".

For stable proxy deployments, set explicit WebAuthn identity to your public
domain:
For stable proxy deployments, set your public URL in `moltis.toml`:

```toml
[server]
external_url = "https://chat.example.com"
```

Moltis derives the WebAuthn RP ID (domain) and origin from this URL
automatically. The `MOLTIS_EXTERNAL_URL` environment variable takes
precedence over the config field, which is useful for container
deployments:

```bash
MOLTIS_BEHIND_PROXY=true
MOLTIS_NO_TLS=true
MOLTIS_EXTERNAL_URL=https://chat.example.com
```

For fine-grained control (e.g. when the RP ID differs from the URL host),
the existing env vars still work and take precedence after
`external_url`:

```bash
MOLTIS_WEBAUTHN_RP_ID=chat.example.com
MOLTIS_WEBAUTHN_ORIGIN=https://chat.example.com
```

Migration guidance when changing host/domain:

1. Keep password login enabled during migration.
2. Deploy with the new `MOLTIS_WEBAUTHN_RP_ID`/`MOLTIS_WEBAUTHN_ORIGIN`.
2. Deploy with the new `server.external_url` (or env var equivalent).
3. Ask users to register a new passkey on the new host.
4. Remove old passkeys after new-host login is confirmed.

Expand Down
Loading