Skip to content

Commit d7acc28

Browse files
committed
Disable external module imports by default and add OPA module policy auditing
External module imports (npm:, jsr:, URL) are now blocked by default. Two new CLI flags control this behavior: - --allow-external-modules: enables external imports - --opa-module-policy: when set with --opa-url, audits each module import against an OPA policy before fetching Includes OPA policy (policies/modules.rego) with allowlists for npm packages, JSR packages, and URL hosts. Adds unit tests, engine integration tests, and e2e tests that spin up a real OPA server to verify policy enforcement. https://claude.ai/code/session_015zV3QdtyMgbizp4ZrSzqjq
1 parent 290329f commit d7acc28

9 files changed

Lines changed: 1033 additions & 33 deletions

File tree

policies/modules.rego

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package mcp.modules
2+
3+
default allow = false
4+
5+
# Allow specific npm packages (via esm.sh)
6+
allow if {
7+
input.specifier_type == "npm"
8+
npm_package_allowed
9+
}
10+
11+
# Allow specific JSR packages (via esm.sh/jsr/)
12+
allow if {
13+
input.specifier_type == "jsr"
14+
jsr_package_allowed
15+
}
16+
17+
# Allow specific URL hosts
18+
allow if {
19+
input.specifier_type == "url"
20+
url_host_allowed
21+
}
22+
23+
# ── Allowed npm packages ────────────────────────────────────────────────
24+
# Add package names (as they appear on esm.sh) to this set.
25+
# The policy checks that the resolved URL host is esm.sh and the path
26+
# starts with one of these package prefixes.
27+
28+
allowed_npm_packages := {
29+
"lodash-es",
30+
"uuid",
31+
"date-fns",
32+
"zod",
33+
}
34+
35+
npm_package_allowed if {
36+
input.url_parsed.host == "esm.sh"
37+
some pkg in allowed_npm_packages
38+
startswith(input.url_parsed.path, sprintf("/%s", [pkg]))
39+
}
40+
41+
# ── Allowed JSR packages ────────────────────────────────────────────────
42+
43+
allowed_jsr_packages := {
44+
"@luca/cases",
45+
"@std/path",
46+
}
47+
48+
jsr_package_allowed if {
49+
input.url_parsed.host == "esm.sh"
50+
some pkg in allowed_jsr_packages
51+
startswith(input.url_parsed.path, sprintf("/jsr/%s", [pkg]))
52+
}
53+
54+
# ── Allowed URL hosts ───────────────────────────────────────────────────
55+
# Direct URL imports are allowed from these exact hosts.
56+
57+
allowed_url_hosts := {
58+
"esm.sh",
59+
"cdn.jsdelivr.net",
60+
"unpkg.com",
61+
}
62+
63+
url_host_allowed if {
64+
allowed_url_hosts[input.url_parsed.host]
65+
}
66+
67+
# Wildcard suffix matches for URL hosts
68+
allowed_url_suffixes := {
69+
".esm.sh",
70+
}
71+
72+
url_host_allowed if {
73+
some suffix in allowed_url_suffixes
74+
endswith(input.url_parsed.host, suffix)
75+
}

server/src/engine/mod.rs

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -721,6 +721,7 @@ pub fn execute_stateless(
721721
wasm_default_max_bytes: usize,
722722
fetch_config: Option<&fetch::FetchConfig>,
723723
console_tree: Option<sled::Tree>,
724+
module_loader_config: Option<&module_loader::ModuleLoaderConfig>,
724725
) -> (Result<String, String>, bool) {
725726
let oom_flag = Arc::new(AtomicBool::new(false));
726727

@@ -739,7 +740,10 @@ pub fn execute_stateless(
739740
// When the code contains ES module syntax (import/export) we need a
740741
// module loader that can resolve npm:/jsr:/URL specifiers.
741742
let module_loader: Option<Rc<dyn deno_core::ModuleLoader>> = if use_modules {
742-
Some(Rc::new(module_loader::NetworkModuleLoader::new()))
743+
match module_loader_config {
744+
Some(config) => Some(Rc::new(module_loader::NetworkModuleLoader::with_config(config.clone()))),
745+
None => Some(Rc::new(module_loader::NetworkModuleLoader::new())),
746+
}
743747
} else {
744748
None
745749
};
@@ -826,6 +830,7 @@ pub fn execute_stateful(
826830
wasm_default_max_bytes: usize,
827831
fetch_config: Option<&fetch::FetchConfig>,
828832
console_tree: Option<sled::Tree>,
833+
module_loader_config: Option<&module_loader::ModuleLoaderConfig>,
829834
) -> (Result<(String, Vec<u8>, String), String>, bool) {
830835
let oom_flag = Arc::new(AtomicBool::new(false));
831836

@@ -860,7 +865,10 @@ pub fn execute_stateful(
860865
}
861866

862867
let module_loader: Option<Rc<dyn deno_core::ModuleLoader>> = if use_modules {
863-
Some(Rc::new(module_loader::NetworkModuleLoader::new()))
868+
match module_loader_config {
869+
Some(config) => Some(Rc::new(module_loader::NetworkModuleLoader::with_config(config.clone()))),
870+
None => Some(Rc::new(module_loader::NetworkModuleLoader::new())),
871+
}
864872
} else {
865873
None
866874
};
@@ -990,6 +998,8 @@ pub struct Engine {
990998
fetch_config: Option<Arc<fetch::FetchConfig>>,
991999
/// Execution registry for async execution tracking and console output.
9921000
execution_registry: Option<Arc<ExecutionRegistry>>,
1001+
/// Module loader configuration controlling external module access and OPA auditing.
1002+
module_loader_config: Arc<module_loader::ModuleLoaderConfig>,
9931003
}
9941004

9951005
impl Engine {
@@ -1010,6 +1020,11 @@ impl Engine {
10101020
wasm_modules: Arc::new(Vec::new()),
10111021
fetch_config: None,
10121022
execution_registry: None,
1023+
module_loader_config: Arc::new(module_loader::ModuleLoaderConfig {
1024+
allow_external: false,
1025+
opa_client: None,
1026+
opa_module_policy: None,
1027+
}),
10131028
}
10141029
}
10151030

@@ -1033,6 +1048,11 @@ impl Engine {
10331048
wasm_modules: Arc::new(Vec::new()),
10341049
fetch_config: None,
10351050
execution_registry: None,
1051+
module_loader_config: Arc::new(module_loader::ModuleLoaderConfig {
1052+
allow_external: false,
1053+
opa_client: None,
1054+
opa_module_policy: None,
1055+
}),
10361056
}
10371057
}
10381058

@@ -1060,6 +1080,12 @@ impl Engine {
10601080
self
10611081
}
10621082

1083+
/// Configure module loader settings (external module access and OPA auditing).
1084+
pub fn with_module_loader_config(mut self, config: module_loader::ModuleLoaderConfig) -> Self {
1085+
self.module_loader_config = Arc::new(config);
1086+
self
1087+
}
1088+
10631089
/// Submit code for async execution. Returns an execution ID immediately.
10641090
/// V8 runs in a background task. Use `get_execution()` to poll status and
10651091
/// `get_execution_output()` to read console output.
@@ -1151,8 +1177,9 @@ impl Engine {
11511177
let wasm_default = self.wasm_default_max_bytes;
11521178
let fc = self.fetch_config.clone();
11531179
let ct = console_tree;
1180+
let mlc = self.module_loader_config.clone();
11541181
let mut join_handle = tokio::task::spawn_blocking(move || {
1155-
execute_stateless(&code, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct))
1182+
execute_stateless(&code, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct), Some(&mlc))
11561183
});
11571184

11581185
// Publish isolate handle for cancellation once it's available.
@@ -1202,11 +1229,12 @@ impl Engine {
12021229
let wasm_default = self.wasm_default_max_bytes;
12031230
let fc = self.fetch_config.clone();
12041231
let ct = console_tree;
1232+
let mlc = self.module_loader_config.clone();
12051233

12061234
let snap_mutex = self.snapshot_mutex.clone();
12071235
let mut join_handle = tokio::task::spawn_blocking(move || {
12081236
let _guard = snap_mutex.blocking_lock();
1209-
execute_stateful(&code, raw_snapshot, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct))
1237+
execute_stateful(&code, raw_snapshot, max_bytes, ih, &wasm, wasm_default, fc.as_deref(), Some(ct), Some(&mlc))
12101238
});
12111239

12121240
// Publish isolate handle for cancellation.

server/src/engine/module_loader.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,69 @@ use deno_core::resolve_import;
1111
use deno_core::FastString;
1212
use deno_error::JsErrorBox;
1313
use futures::FutureExt;
14+
use serde::Serialize;
15+
16+
use super::opa::OpaClient;
17+
18+
/// Configuration for the module loader controlling external module access.
19+
#[derive(Clone, Debug)]
20+
pub struct ModuleLoaderConfig {
21+
/// When false, all external module imports (npm:, jsr:, URL) are rejected.
22+
pub allow_external: bool,
23+
/// Optional OPA client for auditing module imports before fetching.
24+
pub opa_client: Option<OpaClient>,
25+
/// OPA policy path for module auditing (e.g. "mcp/modules").
26+
pub opa_module_policy: Option<String>,
27+
}
28+
29+
/// Input sent to OPA for module import auditing.
30+
#[derive(Serialize)]
31+
struct ModulePolicyInput {
32+
/// The original specifier as written in code (e.g. "npm:lodash-es@4.17.21").
33+
specifier: String,
34+
/// The type of specifier: "npm", "jsr", "url", or "relative".
35+
specifier_type: String,
36+
/// The resolved URL that will be fetched (e.g. "https://esm.sh/lodash-es@4.17.21").
37+
resolved_url: String,
38+
/// Parsed components of the resolved URL.
39+
url_parsed: ModuleUrlParsed,
40+
}
41+
42+
#[derive(Serialize)]
43+
struct ModuleUrlParsed {
44+
scheme: String,
45+
host: String,
46+
path: String,
47+
}
1448

1549
/// Module loader that resolves `npm:`, `jsr:`, and URL imports by fetching
1650
/// them from the network. NPM and JSR specifiers are rewritten to esm.sh
1751
/// URLs so that packages are served as standard ES modules.
52+
///
53+
/// When `allow_external` is false, all external module imports are rejected
54+
/// at resolution time. When an OPA module policy is configured, each module
55+
/// is audited against the policy before being fetched from the network.
1856
pub struct NetworkModuleLoader {
1957
client: reqwest::Client,
58+
config: ModuleLoaderConfig,
2059
}
2160

2261
impl NetworkModuleLoader {
2362
pub fn new() -> Self {
2463
Self {
2564
client: reqwest::Client::new(),
65+
config: ModuleLoaderConfig {
66+
allow_external: true,
67+
opa_client: None,
68+
opa_module_policy: None,
69+
},
70+
}
71+
}
72+
73+
pub fn with_config(config: ModuleLoaderConfig) -> Self {
74+
Self {
75+
client: reqwest::Client::new(),
76+
config,
2677
}
2778
}
2879
}
@@ -36,20 +87,41 @@ impl ModuleLoader for NetworkModuleLoader {
3687
) -> Result<ModuleSpecifier, JsErrorBox> {
3788
// npm:cowsay@1.6.0 → https://esm.sh/cowsay@1.6.0
3889
if let Some(rest) = specifier.strip_prefix("npm:") {
90+
if !self.config.allow_external {
91+
return Err(JsErrorBox::generic(format!(
92+
"External module imports are disabled. Cannot import npm package '{}'. \
93+
Start the server with --allow-external-modules to enable.",
94+
specifier
95+
)));
96+
}
3997
let url = format!("https://esm.sh/{}", rest);
4098
return ModuleSpecifier::parse(&url)
4199
.map_err(|e| JsErrorBox::generic(format!("Bad npm specifier '{}': {}", specifier, e)));
42100
}
43101

44102
// jsr:@luca/cases@1.0.0 → https://esm.sh/jsr/@luca/cases@1.0.0
45103
if let Some(rest) = specifier.strip_prefix("jsr:") {
104+
if !self.config.allow_external {
105+
return Err(JsErrorBox::generic(format!(
106+
"External module imports are disabled. Cannot import JSR package '{}'. \
107+
Start the server with --allow-external-modules to enable.",
108+
specifier
109+
)));
110+
}
46111
let url = format!("https://esm.sh/jsr/{}", rest);
47112
return ModuleSpecifier::parse(&url)
48113
.map_err(|e| JsErrorBox::generic(format!("Bad jsr specifier '{}': {}", specifier, e)));
49114
}
50115

51116
// Absolute URLs pass through directly.
52117
if specifier.starts_with("https://") || specifier.starts_with("http://") {
118+
if !self.config.allow_external {
119+
return Err(JsErrorBox::generic(format!(
120+
"External module imports are disabled. Cannot import URL module '{}'. \
121+
Start the server with --allow-external-modules to enable.",
122+
specifier
123+
)));
124+
}
53125
return ModuleSpecifier::parse(specifier)
54126
.map_err(|e| JsErrorBox::generic(format!("Bad URL '{}': {}", specifier, e)));
55127
}
@@ -76,7 +148,56 @@ impl ModuleLoader for NetworkModuleLoader {
76148

77149
let client = self.client.clone();
78150
let specifier = module_specifier.clone();
151+
let opa_client = self.config.opa_client.clone();
152+
let opa_policy = self.config.opa_module_policy.clone();
153+
let specifier_url_str = specifier.to_string();
154+
79155
let fut = async move {
156+
// OPA module audit: check with policy before fetching
157+
if let (Some(opa), Some(policy_path)) = (&opa_client, &opa_policy) {
158+
let parsed = url::Url::parse(specifier_url_str.as_str()).ok();
159+
let url_parsed = parsed.as_ref().map(|p| ModuleUrlParsed {
160+
scheme: p.scheme().to_string(),
161+
host: p.host_str().unwrap_or("").to_string(),
162+
path: p.path().to_string(),
163+
}).unwrap_or(ModuleUrlParsed {
164+
scheme: String::new(),
165+
host: String::new(),
166+
path: String::new(),
167+
});
168+
169+
// Determine specifier type from the resolved URL
170+
let spec_type = if specifier_url_str.contains("esm.sh/jsr/") {
171+
"jsr"
172+
} else if specifier_url_str.contains("esm.sh/") {
173+
"npm"
174+
} else {
175+
"url"
176+
};
177+
178+
let policy_input = ModulePolicyInput {
179+
specifier: specifier_url_str.clone(),
180+
specifier_type: spec_type.to_string(),
181+
resolved_url: specifier_url_str.clone(),
182+
url_parsed,
183+
};
184+
185+
let allowed = opa
186+
.evaluate(policy_path, &policy_input)
187+
.await
188+
.map_err(|e| JsErrorBox::generic(format!(
189+
"OPA module policy check failed for '{}': {}",
190+
specifier, e
191+
)))?;
192+
193+
if !allowed {
194+
return Err(JsErrorBox::generic(format!(
195+
"Module import denied by policy: '{}' is not allowed by the module policy",
196+
specifier
197+
)));
198+
}
199+
}
200+
80201
let resp = client
81202
.get(specifier.as_str())
82203
.send()

0 commit comments

Comments
 (0)