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