greentic_ext_runtime/loaded.rs
1use std::path::{Path, PathBuf};
2use std::sync::Arc;
3
4use greentic_extension_sdk_contract::{DescribeJson, ExtensionKind};
5use wasmtime::Store;
6use wasmtime::component::{Component, HasSelf, Instance, Linker};
7
8use crate::health::ExtensionHealth;
9use crate::host_state::HostState;
10use crate::pool::InstancePool;
11
12#[derive(Clone, Debug, PartialEq, Eq, Hash)]
13pub struct ExtensionId(pub String);
14
15impl ExtensionId {
16 #[must_use]
17 pub fn from_describe(describe: &DescribeJson) -> Self {
18 Self(describe.metadata.id.clone())
19 }
20
21 #[must_use]
22 pub fn as_str(&self) -> &str {
23 &self.0
24 }
25}
26
27impl From<&str> for ExtensionId {
28 fn from(s: &str) -> Self {
29 Self(s.to_string())
30 }
31}
32
33impl From<String> for ExtensionId {
34 fn from(s: String) -> Self {
35 Self(s)
36 }
37}
38
39pub struct LoadedExtension {
40 pub id: ExtensionId,
41 pub describe: Arc<DescribeJson>,
42 pub kind: ExtensionKind,
43 pub source_dir: PathBuf,
44 pub component: Component,
45 pub pool: InstancePool,
46 pub health: ExtensionHealth,
47}
48
49/// Deserialize a validated describe `Value` into a typed [`DescribeJson`],
50/// migrating a `greentic.ai/v1` describe to the current (v2) shape first.
51///
52/// The bundled fallback extensions — and any extension authored before the
53/// contract 1.1 bump — are v1: `contributions.knowledge` is an array of path
54/// strings, `engine` stands in for `compat`, etc. Those do not deserialize
55/// into the typed 1.1 structs, so a straight `from_value` fails with a raw
56/// "expected struct Knowledge". The contract crate already knows how to lift a
57/// v0.4/v1 describe to v2 (`migrate_v0_4_x_value`); run it on read so old
58/// describes load instead of being rejected. A v2 describe passes through
59/// untouched.
60pub(crate) fn describe_from_value(value: serde_json::Value) -> anyhow::Result<DescribeJson> {
61 const V1_API_VERSION: &str = "greentic.ai/v1";
62 let is_v1 = value.get("apiVersion").and_then(serde_json::Value::as_str) == Some(V1_API_VERSION);
63 let value = if is_v1 {
64 let (migrated, _report) = greentic_extension_sdk_contract::migrate_v0_4_x_value(&value)
65 .map_err(|e| anyhow::anyhow!("migrate v1 describe.json to v2: {e}"))?;
66 migrated
67 } else {
68 value
69 };
70 Ok(serde_json::from_value(value)?)
71}
72
73impl LoadedExtension {
74 pub fn load_from_dir(engine: &wasmtime::Engine, source_dir: &Path) -> anyhow::Result<Self> {
75 let describe_path = source_dir.join("describe.json");
76 let describe_bytes = std::fs::read(&describe_path)?;
77 let describe_value: serde_json::Value = serde_json::from_slice(&describe_bytes)?;
78 greentic_extension_sdk_contract::schema::validate_describe_json(&describe_value)
79 .map_err(|e| anyhow::anyhow!("invalid describe.json: {e}"))?;
80 let describe = describe_from_value(describe_value)?;
81 let id = ExtensionId::from_describe(&describe);
82 let wasm_path = wasm_component_path(&describe, source_dir)?;
83 let component = Component::from_file(engine, &wasm_path)?;
84 let pool = InstancePool::new(2);
85 let kind = describe.kind;
86 Ok(Self {
87 id,
88 describe: Arc::new(describe),
89 kind,
90 source_dir: source_dir.to_path_buf(),
91 component,
92 pool,
93 health: ExtensionHealth::Healthy,
94 })
95 }
96}
97
98impl LoadedExtension {
99 /// Build a fresh wasmtime Store with [`HostState`] and instantiate the component.
100 /// Each call creates a new instance (no pooling yet — pooling is future work).
101 pub fn build_store_and_instance(
102 &self,
103 engine: &wasmtime::Engine,
104 host_overrides: HostOverrides,
105 ctx: &crate::host_ports::HostCallContext,
106 ) -> anyhow::Result<(Store<HostState>, Instance)> {
107 use crate::host_bindings::greentic::extension_host::{
108 broker, http, i18n, llm, logging, secrets,
109 };
110
111 let mut linker: Linker<HostState> = Linker::new(engine);
112
113 // Wire WASI host functions. cargo-component always adds WASI imports to
114 // its output even when the Rust source never calls them directly.
115 wasmtime_wasi::p2::add_to_linker_sync(&mut linker)?;
116
117 // HasSelf<T> wraps T and implements HasData — required for wasmtime 43 bindgen add_to_linker.
118 logging::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
119 i18n::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
120 secrets::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
121 broker::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
122 http::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
123 llm::add_to_linker::<HostState, HasSelf<HostState>>(&mut linker, |s| s)?;
124 crate::host_bindings::design_v04::greentic::oauth_broker::broker_v1::add_to_linker::<
125 HostState,
126 HasSelf<HostState>,
127 >(&mut linker, |s| s)?;
128
129 // Per-extension network allow-list: when the extension declares
130 // `runtime.permissions.network` patterns, those patterns become the
131 // authoritative allow-list for this extension (replace semantics —
132 // the host-level override is NOT added). When no patterns are
133 // declared the host-level override is used unchanged (deny-all by
134 // default). See `effective_url_matcher` for the loopback-http rule.
135 let url_matcher = effective_url_matcher(
136 &self.describe.runtime.permissions.network,
137 host_overrides.url_matcher,
138 );
139
140 let state = HostState::builder(
141 self.id.as_str().to_string(),
142 self.describe.runtime.permissions.clone(),
143 )
144 .translator(host_overrides.translator)
145 .secrets_backend(host_overrides.secrets_backend)
146 .http_client(host_overrides.http_client)
147 .llm_port(host_overrides.llm_port)
148 .call_ctx(ctx.clone())
149 .url_matcher(url_matcher)
150 .runtime_weak(host_overrides.runtime_weak)
151 .call_depth_start(host_overrides.call_depth_start)
152 .oauth_config(host_overrides.oauth_config.clone())
153 .build();
154
155 let mut store = Store::new(engine, state);
156 let instance = linker.instantiate(&mut store, &self.component)?;
157 Ok((store, instance))
158 }
159}
160
161/// Resolve the WASM component path for an extension's runtime component.
162///
163/// Extensions that use the dual-component layout ship:
164/// - Root `extension.wasm` — design-side WebAssembly with metadata (channel
165/// name, icon, i18n, schemas). This is what the designer loads.
166/// - A runtime gtpack (e.g. `runtime/provider.gtpack`) — either a placeholder
167/// text file or a real .gtpack ZIP. The real runner-host WASM lives downstream
168/// and is fetched lazily there; the designer must never try to parse it.
169///
170/// Multiple extension kinds follow this dual-component layout:
171/// - `ProviderExtension` (e.g. `greentic.provider.telegram-1.3.1-research`)
172/// - `DesignExtension` (e.g. `greentic.llm-openai-1.3.1-research`) — has a
173/// real 80–900 KB `extension.wasm`; `describe.json` points at
174/// `runtime/component-llm-openai.gtpack` (a 929 KB .gtpack ZIP that wasmtime
175/// cannot parse as a raw component).
176/// - `BundleExtension` (e.g. `greentic.bundle-standard-1.3.0-research`) — has
177/// a 938 KB `extension.wasm`; `describe.json` points at a `.gtxpack` that
178/// may not even exist in the installed directory.
179///
180/// Strategy: if `<source_dir>/extension.wasm` exists, prefer it unconditionally
181/// regardless of kind. Only the runner-host — which has its own separate loader
182/// path — needs the runtime gtpack declared in `describe.runtime.components`.
183/// Designer's boot loader only consumes design-side metadata and UI assets.
184///
185/// Older single-component extensions that ship no `extension.wasm` at root fall
186/// back to `describe.runtime.components[X].gtpack.file` resolved relative to
187/// `source_dir`, exactly as before.
188///
189/// v2's `runtime.components` is a map keyed by component id. ext-runtime today
190/// loads a single WASM component per extension, so we require exactly one entry.
191/// Multi-component dispatch (driven by `runtime_ref` on nodeTypes/tools) is a
192/// follow-up — when it lands, callers will pick the component by id and this
193/// helper goes away.
194fn wasm_component_path(describe: &DescribeJson, source_dir: &Path) -> anyhow::Result<PathBuf> {
195 // Dual-component layout: extensions that ship a design-side `extension.wasm`
196 // at the source-dir root use it for designer-side loading regardless of kind.
197 // The runtime gtpack declared in `describe.runtime.components` stays meaningful
198 // for runner-host (flow-execution time), which has its own separate loader path.
199 //
200 // Provider, llm-openai (DesignExtension), and bundle-standard (BundleExtension)
201 // all follow this layout. Older single-component extensions that don't ship
202 // `extension.wasm` fall back to the describe.json declared path below.
203 let design_wasm = source_dir.join("extension.wasm");
204 if design_wasm.exists() {
205 return Ok(design_wasm);
206 }
207
208 // Fallback for older single-component extensions: read
209 // `describe.runtime.components[X].gtpack.file` and resolve it relative to
210 // `source_dir`. These kinds already point at real WASM at that path.
211 let mut iter = describe.runtime.components.iter();
212 let Some((id, component)) = iter.next() else {
213 anyhow::bail!("describe.runtime.components must declare at least one entry");
214 };
215 if iter.next().is_some() {
216 anyhow::bail!(
217 "describe.runtime.components has more than one entry; multi-component dispatch is not yet implemented"
218 );
219 }
220 let gtpack = component.gtpack.as_ref().ok_or_else(|| {
221 anyhow::anyhow!(
222 "describe.runtime.components[{id:?}].gtpack must be set for source-dir loads (OCI-only deploy is not yet supported)",
223 )
224 })?;
225 Ok(source_dir.join(gtpack.file.as_str()))
226}
227
228/// Select the URL matcher for a single extension instantiation.
229///
230/// **Replace semantics:** when the extension's `describe.json` declares one
231/// or more patterns under `runtime.permissions.network`, those patterns are
232/// the authoritative allow-list for that extension and a fresh
233/// [`UrlMatcher`] is built from them (with the loopback-http rule applied —
234/// see below). The host-level `override_matcher` is **ignored** in this
235/// path — it is the host-wide default that applies only to extensions that
236/// make no network declaration.
237///
238/// When the declaration is empty the host-level override is returned
239/// unchanged, which is the deny-all default in most deployments. This
240/// preserves existing behavior for extensions that do not need outbound HTTP.
241///
242/// # Loopback-http rule
243///
244/// [`UrlMatcher`] rejects non-`https` URLs by default (scheme-downgrade
245/// defence) and only honours plain `http` when `with_allow_http(true)` is
246/// set. That toggle is **matcher-wide** — it cannot be scoped to a single
247/// pattern. To let an extension talk to a local dev service over
248/// `http://127.0.0.1` / `http://localhost` WITHOUT also opening plain http
249/// to public hosts, we:
250///
251/// 1. drop any declared `http://` pattern whose host is NOT loopback (it
252/// could never be safely honoured — a public-host plain-http downgrade
253/// is exactly the attack the matcher defends against), and
254/// 2. enable `with_allow_http(true)` only when at least one *loopback*
255/// `http://` pattern survives.
256///
257/// Because the matcher matches scheme exactly per declared pattern, a
258/// co-declared `https://host/*` pattern still requires `https` even when
259/// the toggle is on — the toggle only decides whether `http` patterns are
260/// consulted at all, and after step 1 the only surviving `http` patterns
261/// are loopback.
262///
263/// # Arguments
264///
265/// * `declared_patterns` — the `runtime.permissions.network` slice from
266/// the extension's parsed `describe.json`.
267/// * `override_matcher` — the host-level matcher supplied via
268/// [`HostOverrides`]. Used only when `declared_patterns` is empty.
269///
270/// # Returns
271///
272/// A [`UrlMatcher`] that enforces the correct allow-list for this extension.
273pub(crate) fn effective_url_matcher(
274 declared_patterns: &[String],
275 override_matcher: crate::url_matcher::UrlMatcher,
276) -> crate::url_matcher::UrlMatcher {
277 if declared_patterns.is_empty() {
278 return override_matcher;
279 }
280
281 // Replace path: build the effective matcher exclusively from the
282 // extension's declared patterns (the host override does NOT apply).
283 let mut patterns: Vec<String> = declared_patterns.to_vec();
284
285 // Loopback-http handling: keep loopback http patterns, drop public-host
286 // http patterns (they can never be honoured safely), and record whether
287 // any loopback http pattern remains so we can flip the matcher-wide
288 // allow_http toggle.
289 let mut allow_loopback_http = false;
290 patterns.retain(|p| {
291 if let Some(host) = http_pattern_host(p) {
292 if is_loopback_host(host) {
293 allow_loopback_http = true;
294 true
295 } else {
296 tracing::warn!(
297 pattern = %p,
298 "dropping non-loopback http url pattern; plain http is only honoured for loopback hosts"
299 );
300 false
301 }
302 } else {
303 // https (or any non-http) pattern — kept verbatim; UrlMatcher
304 // validates it on construction.
305 true
306 }
307 });
308
309 crate::url_matcher::UrlMatcher::from_patterns(patterns).with_allow_http(allow_loopback_http)
310}
311
312/// Return the host portion of a `http://` pattern, or `None` when the
313/// pattern is not plain http. The leading `*.` wildcard label (e.g.
314/// `http://*.example.com/*`) is stripped so the remaining host can be
315/// classified; a bare wildcard host is treated as non-loopback.
316///
317/// Bracketed IPv6 literals (e.g. `[::1]` in `http://[::1]:8787/*`) are
318/// returned with their brackets intact so that `is_loopback_host` can strip
319/// them: splitting on the first `:` would otherwise yield the bare `"["`
320/// opener and misclassify `[::1]` as non-loopback.
321fn http_pattern_host(pattern: &str) -> Option<&str> {
322 let rest = pattern.strip_prefix("http://")?;
323 let host_and_port = rest.split('/').next().unwrap_or(rest);
324 // Strip the userinfo (`user@host`) if present.
325 let host_and_port = host_and_port.rsplit('@').next().unwrap_or(host_and_port);
326 // Bracketed IPv6 literal: `[::1]` or `[::1]:8787`.
327 // Return the bracketed token (including the `]`) so is_loopback_host can
328 // strip the brackets and compare against `::1`.
329 let host = if let Some(bracket_end) = host_and_port.find(']') {
330 &host_and_port[..=bracket_end]
331 } else {
332 // Plain hostname or IPv4: split on first `:` to drop optional port.
333 host_and_port.split(':').next().unwrap_or(host_and_port)
334 };
335 Some(host.trim_start_matches("*."))
336}
337
338/// Loopback hosts for which plain http is acceptable: `localhost`,
339/// `127.0.0.1` (any IPv4 loopback in `127.0.0.0/8` would also qualify, but
340/// the only spellings extensions declare in practice are these two and
341/// `[::1]`), and the IPv6 loopback.
342fn is_loopback_host(host: &str) -> bool {
343 let host = host.trim_start_matches('[').trim_end_matches(']');
344 host.eq_ignore_ascii_case("localhost") || host == "127.0.0.1" || host == "::1"
345}
346
347pub type LoadedExtensionRef = Arc<LoadedExtension>;
348
349/// Bundle of overrides every dispatch caller must supply when building a
350/// `HostState`. Production code (designer) constructs adapters around
351/// `greentic-i18n` + `greentic-secrets`; tests use [`HostOverrides::defaults_for_tests`].
352///
353/// `http_client` is `Option` because `reqwest::blocking::Client` spawns an
354/// internal tokio runtime, and dropping that runtime from inside an
355/// outer async context panics with "Cannot drop a runtime in a context
356/// where blocking is not allowed". Tests instantiate `ExtensionRuntime`
357/// inside `#[tokio::test]` bodies but never call `http::fetch`, so
358/// they leave the client `None` — `host_state` will surface a clean
359/// "http client not configured" error if a test ever does invoke fetch.
360/// Production callers pass `Some(client)` once at startup.
361#[derive(Clone)]
362pub struct HostOverrides {
363 pub translator: std::sync::Arc<dyn crate::host_ports::Translator>,
364 pub secrets_backend: std::sync::Arc<dyn crate::host_ports::SecretsBackend>,
365 pub http_client: Option<reqwest::blocking::Client>,
366 pub llm_port: Option<std::sync::Arc<dyn crate::host_ports::LlmPort>>,
367 pub url_matcher: crate::url_matcher::UrlMatcher,
368 pub runtime_weak: std::sync::Weak<crate::runtime::ExtensionRuntime>,
369 pub call_depth_start: u32,
370 pub oauth_config: Option<crate::oauth::OAuthBrokerConfig>,
371}
372
373impl std::fmt::Debug for HostOverrides {
374 /// Opaque debug representation: trait-object fields cannot provide
375 /// structural debug output, and `reqwest::blocking::Client` does not
376 /// implement `Debug`. We show field presence rather than field values.
377 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
378 f.debug_struct("HostOverrides")
379 .field("translator", &"<dyn Translator>")
380 .field("secrets_backend", &"<dyn SecretsBackend>")
381 .field(
382 "http_client",
383 &self.http_client.as_ref().map(|_| "<Client>"),
384 )
385 .field("llm_port", &self.llm_port.as_ref().map(|_| "<dyn LlmPort>"))
386 .field("url_matcher", &self.url_matcher)
387 .field(
388 "runtime_weak",
389 &self
390 .runtime_weak
391 .upgrade()
392 .map(|_| "<Arc<ExtensionRuntime>>"),
393 )
394 .field("call_depth_start", &self.call_depth_start)
395 .field(
396 "oauth_config",
397 &self.oauth_config.as_ref().map(|_| "<OAuthBrokerConfig>"),
398 )
399 .finish()
400 }
401}
402
403impl HostOverrides {
404 /// Fakes-everywhere helper. `http_client` is `None` so dropping the
405 /// runtime inside an outer async context never panics; the test never
406 /// hits the path that uses it. Runtime weak is left unset (`Weak::new`),
407 /// so broker dispatch returns "no runtime context available" until
408 /// the cross-extension dispatch cascade lands.
409 #[must_use]
410 pub fn defaults_for_tests() -> Self {
411 Self::default()
412 }
413}
414
415impl Default for HostOverrides {
416 /// Production-safe defaults: key-pass-through translator (i18n key
417 /// returned verbatim), empty in-memory secrets, no HTTP client (callers
418 /// that need HTTP must supply `Some(client)` via
419 /// `RuntimeConfig::with_host_overrides` or
420 /// `ExtensionRuntime::with_host_overrides`), empty URL allow-list, and
421 /// no broker-runtime weak reference (cross-extension dispatch returns
422 /// "no runtime context available" until the cascade cascade lands).
423 ///
424 /// `http_client` is intentionally `None` rather than eagerly constructed
425 /// because `reqwest::blocking::Client` spawns its own internal tokio
426 /// runtime; dropping that runtime from inside an outer `#[tokio::test]`
427 /// body panics with "Cannot drop a runtime in a context where blocking is
428 /// not allowed". Tests leave it `None`; production callers pass
429 /// `Some(client)` once at startup.
430 fn default() -> Self {
431 Self {
432 translator: std::sync::Arc::new(crate::host_ports::KeyTranslator),
433 secrets_backend: std::sync::Arc::new(crate::host_ports::InMemorySecrets::new()),
434 http_client: None,
435 llm_port: None,
436 url_matcher: crate::url_matcher::UrlMatcher::default(),
437 runtime_weak: std::sync::Weak::new(),
438 call_depth_start: 0,
439 oauth_config: None,
440 }
441 }
442}
443
444#[cfg(test)]
445mod tests {
446 use super::*;
447 use crate::url_matcher::UrlMatcher;
448
449 /// The real bundled Adaptive Cards describe — `apiVersion: greentic.ai/v1`,
450 /// with `contributions.knowledge` as an array of path strings — which does
451 /// not deserialize into the contract 1.1 typed structs. Before this fix a
452 /// straight `from_value` failed with "expected struct Knowledge", so every
453 /// bundled fallback extension (all v1) was unloadable under the 1.1 runtime.
454 /// It must now migrate on read and load.
455 ///
456 /// Uses the actual bundled describe rather than a hand-built minimal one so
457 /// the test can't drift from the real v1 shape the runtime must accept.
458 const AC_V1_DESCRIBE: &str = include_str!("testdata/ac_v1_describe.json");
459
460 #[test]
461 fn describe_from_value_migrates_the_bundled_v1_describe() {
462 let value: serde_json::Value =
463 serde_json::from_str(AC_V1_DESCRIBE).expect("fixture is valid JSON");
464 assert_eq!(
465 value.get("apiVersion").and_then(|v| v.as_str()),
466 Some("greentic.ai/v1"),
467 "fixture must be a v1 describe for this test to mean anything"
468 );
469
470 let describe = describe_from_value(value).expect("bundled v1 describe must migrate + load");
471 assert_eq!(describe.metadata.id, "greentic.adaptive-cards");
472 }
473
474 fn empty_override() -> UrlMatcher {
475 UrlMatcher::default()
476 }
477
478 fn override_with_pattern(pattern: &str) -> UrlMatcher {
479 UrlMatcher::from_patterns(vec![pattern.to_string()])
480 }
481
482 /// Extensions that declare network patterns must have exactly those
483 /// patterns enforced — the host-level override must NOT apply.
484 #[test]
485 fn declared_patterns_allow_declared_host_and_deny_undeclared() {
486 let declared = vec!["https://api.github.com/*".to_string()];
487 let matcher = effective_url_matcher(&declared, empty_override());
488
489 assert!(
490 matcher.is_allowed("https://api.github.com/repos/org/repo"),
491 "declared host must be allowed"
492 );
493 assert!(
494 !matcher.is_allowed("https://evil.com/"),
495 "undeclared host must be denied even though host override is empty"
496 );
497 }
498
499 /// When no network patterns are declared the host-level override is
500 /// returned verbatim — behavior is unchanged for legacy extensions.
501 #[test]
502 fn empty_declaration_falls_back_to_host_override() {
503 let override_matcher = override_with_pattern("https://allowed.com/*");
504 let matcher = effective_url_matcher(&[], override_matcher);
505
506 assert!(
507 matcher.is_allowed("https://allowed.com/path"),
508 "host-override host must be reachable when declare is empty"
509 );
510 assert!(
511 !matcher.is_allowed("https://other.com/path"),
512 "host-override deny must still apply"
513 );
514 }
515
516 /// Non-empty declaration REPLACES (not unions) the host-level
517 /// override. A broader operator override must not bleed through to
518 /// an extension that declared its own narrower allow-list.
519 #[test]
520 fn declared_patterns_replace_not_union_host_override() {
521 let declared = vec!["https://api.github.com/*".to_string()];
522 let override_matcher = override_with_pattern("https://operator-allowed.com/*");
523 let matcher = effective_url_matcher(&declared, override_matcher);
524
525 assert!(
526 matcher.is_allowed("https://api.github.com/repos/org/repo"),
527 "declared host must be allowed"
528 );
529 assert!(
530 !matcher.is_allowed("https://operator-allowed.com/anything"),
531 "operator override must NOT bleed through when declaration is non-empty"
532 );
533 }
534
535 /// Empty declaration + empty host override must deny every URL —
536 /// this is the default deny-all posture for extensions that never
537 /// call the network.
538 #[test]
539 fn empty_declaration_and_empty_override_denies_everything() {
540 let matcher = effective_url_matcher(&[], empty_override());
541
542 assert!(
543 !matcher.is_allowed("https://api.github.com/anything"),
544 "empty declaration + empty override must produce deny-all matcher"
545 );
546 }
547
548 /// A declared loopback `http://127.0.0.1` pattern must be reachable
549 /// over plain http. The matcher rejects non-https by default, so the
550 /// effective matcher has to opt http in — but ONLY because the
551 /// declared pattern is loopback.
552 #[test]
553 fn declared_http_loopback_127_allows_plain_http() {
554 let declared = vec!["http://127.0.0.1:8787/*".to_string()];
555 let matcher = effective_url_matcher(&declared, empty_override());
556
557 assert!(
558 matcher.is_allowed("http://127.0.0.1:8787/execute"),
559 "declared http loopback pattern must permit plain http to that loopback"
560 );
561 }
562
563 /// `http://localhost` is the other loopback spelling and must behave
564 /// the same as `127.0.0.1`.
565 #[test]
566 fn declared_http_loopback_localhost_allows_plain_http() {
567 let declared = vec!["http://localhost:8787/*".to_string()];
568 let matcher = effective_url_matcher(&declared, empty_override());
569
570 assert!(
571 matcher.is_allowed("http://localhost:8787/execute"),
572 "declared http localhost pattern must permit plain http to localhost"
573 );
574 }
575
576 /// The loopback-http opt-in must NOT leak to non-loopback http: a
577 /// declared `http://evil.com` pattern must stay denied (no plain-http
578 /// downgrade for a public host) even though the pattern technically
579 /// targets http.
580 #[test]
581 fn declared_http_non_loopback_stays_denied() {
582 let declared = vec!["http://evil.com/*".to_string()];
583 let matcher = effective_url_matcher(&declared, empty_override());
584
585 assert!(
586 !matcher.is_allowed("http://evil.com/anything"),
587 "plain http must stay denied for a non-loopback declared host"
588 );
589 }
590
591 /// A mixed declaration (loopback http + a normal https host) must keep
592 /// https reachable AND the loopback http reachable, while still
593 /// refusing plain http to the https host (the global `allow_http` toggle
594 /// must not downgrade the https-only host because no http pattern for
595 /// it exists, and `is_allowed` matches scheme exactly per pattern).
596 #[test]
597 fn mixed_loopback_http_and_https_host() {
598 let declared = vec![
599 "http://127.0.0.1:8787/*".to_string(),
600 "https://api.example.com/*".to_string(),
601 ];
602 let matcher = effective_url_matcher(&declared, empty_override());
603
604 assert!(
605 matcher.is_allowed("http://127.0.0.1:8787/execute"),
606 "loopback http must be allowed in a mixed declaration"
607 );
608 assert!(
609 matcher.is_allowed("https://api.example.com/v1/foo"),
610 "declared https host must stay reachable"
611 );
612 assert!(
613 !matcher.is_allowed("http://api.example.com/v1/foo"),
614 "plain http to the https-only host must stay denied even with loopback http enabled"
615 );
616 }
617
618 /// A bracketed IPv6 loopback `http://[::1]:8787/*` must survive the
619 /// loopback filter and allow plain http to `http://[::1]:8787/x`.
620 ///
621 /// The url crate's `host_str()` returns the bracketed form `"[::1]"` for
622 /// both the pattern and the request URL, so the Exact host rule matches.
623 /// The bug this test guards against: `http_pattern_host` previously split
624 /// on the first `:`, yielding `"["` as the host, which was classified as
625 /// non-loopback and dropped.
626 #[test]
627 fn declared_http_ipv6_loopback_allows_plain_http() {
628 let declared = vec!["http://[::1]:8787/*".to_string()];
629 let matcher = effective_url_matcher(&declared, empty_override());
630
631 assert!(
632 matcher.is_allowed("http://[::1]:8787/x"),
633 "declared http IPv6 loopback pattern must permit plain http to [::1]"
634 );
635 // Must not bleed to arbitrary non-loopback hosts.
636 assert!(
637 !matcher.is_allowed("http://evil.com/x"),
638 "IPv6 loopback opt-in must not permit plain http to non-loopback hosts"
639 );
640 }
641
642 /// An adversarial pattern `http://[::1].evil.com/*` that tries to smuggle
643 /// a non-loopback host inside brackets must be rejected. The url crate
644 /// refuses to parse this (it is not a valid bracketed IPv6 literal), so
645 /// the pattern is either unparseable (dropped by `UrlMatcher`) or the
646 /// resulting host does not match `[::1]` in `is_loopback_host`.
647 ///
648 /// Either way the request to `http://[::1].evil.com/x` must be denied.
649 #[test]
650 fn adversarial_fake_ipv6_bracket_host_is_denied() {
651 let declared = vec!["http://[::1].evil.com/*".to_string()];
652 let matcher = effective_url_matcher(&declared, empty_override());
653
654 // The pattern is malformed: url::Url::parse rejects `[::1].evil.com`
655 // as a host, so the pattern is silently dropped and the matcher
656 // remains deny-all for this declaration.
657 assert!(
658 !matcher.is_allowed("http://[::1].evil.com/x"),
659 "malformed bracketed host must not be allowed"
660 );
661 // Real IPv6 loopback must also NOT be granted by a bad pattern.
662 assert!(
663 !matcher.is_allowed("http://[::1]/x"),
664 "bad pattern must not accidentally allow real IPv6 loopback"
665 );
666 }
667}