Skip to content

Commit 3f21fd7

Browse files
committed
feat: remote module security
1 parent 1f983fe commit 3f21fd7

File tree

7 files changed

+446
-1
lines changed

7 files changed

+446
-1
lines changed

test-app/app/src/main/assets/app/package.json

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,12 @@
1313
"enableLineBreakpoints": false,
1414
"enableMultithreadedJavascript": true
1515
},
16-
"discardUncaughtJsExceptions": false
16+
"discardUncaughtJsExceptions": false,
17+
"security": {
18+
"allowRemoteModules": true,
19+
"remoteModuleAllowlist": [
20+
"https://cdn.example.com/modules/",
21+
"https://esm.sh/"
22+
]
23+
}
1724
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
//
2+
// Security configuration
3+
// {
4+
// "security": {
5+
// "allowRemoteModules": true, // Enable remote module loading in production
6+
// "remoteModuleAllowlist": [ // Optional: restrict to specific URL prefixes
7+
// "https://cdn.example.com/modules/",
8+
// "https://esm.sh/"
9+
// ]
10+
// }
11+
// }
12+
//
13+
// Behavior:
14+
// - Debug mode: Remote modules always allowed
15+
// - Production mode: Requires security.allowRemoteModules = true
16+
// - With allowlist: Only URLs matching a prefix in remoteModuleAllowlist are allowed
17+
18+
describe("Remote Module Security", function() {
19+
20+
describe("Debug Mode Behavior", function() {
21+
22+
it("should allow HTTP module imports in debug mode", function(done) {
23+
// This test uses a known unreachable IP to trigger the HTTP loading path
24+
// In debug mode, the security check passes and we get a network error
25+
// (not a security error)
26+
import("http://192.0.2.1:5173/test-module.js").then(function(module) {
27+
// If we somehow succeed, that's fine too
28+
expect(module).toBeDefined();
29+
done();
30+
}).catch(function(error) {
31+
// Should fail with a network/timeout error, NOT a security error
32+
var message = error.message || String(error);
33+
// In debug mode, we should NOT see security-related error messages
34+
expect(message).not.toContain("not allowed in production");
35+
expect(message).not.toContain("remoteModuleAllowlist");
36+
done();
37+
});
38+
});
39+
40+
it("should allow HTTPS module imports in debug mode", function(done) {
41+
// Test HTTPS URL - should be allowed in debug mode
42+
import("https://192.0.2.1:5173/test-module.js").then(function(module) {
43+
expect(module).toBeDefined();
44+
done();
45+
}).catch(function(error) {
46+
var message = error.message || String(error);
47+
// Should NOT be a security error in debug mode
48+
expect(message).not.toContain("not allowed in production");
49+
expect(message).not.toContain("remoteModuleAllowlist");
50+
done();
51+
});
52+
});
53+
});
54+
55+
describe("Security Configuration", function() {
56+
57+
it("should have security configuration in package.json", function() {
58+
var context = com.tns.Runtime.getCurrentRuntime().getContext();
59+
var assetManager = context.getAssets();
60+
61+
try {
62+
var inputStream = assetManager.open("app/package.json");
63+
var reader = new java.io.BufferedReader(new java.io.InputStreamReader(inputStream));
64+
var sb = new java.lang.StringBuilder();
65+
var line;
66+
67+
while ((line = reader.readLine()) !== null) {
68+
sb.append(line);
69+
}
70+
reader.close();
71+
72+
var jsonString = sb.toString();
73+
var config = JSON.parse(jsonString);
74+
75+
// Verify security config structure
76+
expect(config.security).toBeDefined();
77+
expect(typeof config.security.allowRemoteModules).toBe("boolean");
78+
expect(Array.isArray(config.security.remoteModuleAllowlist)).toBe(true);
79+
} catch (e) {
80+
fail("Failed to read package.json: " + e.message);
81+
}
82+
});
83+
84+
it("should parse security allowRemoteModules from package.json", function() {
85+
var allowed = com.tns.Runtime.getSecurityAllowRemoteModules();
86+
expect(typeof allowed).toBe("boolean");
87+
expect(allowed).toBe(true); // Matches our test package.json config
88+
});
89+
90+
it("should parse security remoteModuleAllowlist from package.json", function() {
91+
var allowlist = com.tns.Runtime.getSecurityRemoteModuleAllowlist();
92+
expect(allowlist).not.toBeNull();
93+
expect(Array.isArray(allowlist)).toBe(true);
94+
expect(allowlist.length).toBeGreaterThan(0);
95+
96+
// Verify our test allowlist entries are present
97+
var hasEsmSh = false;
98+
var hasCdn = false;
99+
for (var i = 0; i < allowlist.length; i++) {
100+
if (allowlist[i].indexOf("esm.sh") !== -1) hasEsmSh = true;
101+
if (allowlist[i].indexOf("cdn.example.com") !== -1) hasCdn = true;
102+
}
103+
expect(hasEsmSh).toBe(true);
104+
expect(hasCdn).toBe(true);
105+
});
106+
});
107+
108+
describe("URL Allowlist Matching", function() {
109+
110+
it("should match URLs in the allowlist (esm.sh)", function() {
111+
// esm.sh is in our test allowlist
112+
var isAllowed = com.tns.Runtime.isRemoteUrlAllowed("https://esm.sh/lodash");
113+
expect(isAllowed).toBe(true);
114+
});
115+
116+
it("should match URLs in the allowlist (cdn.example.com)", function() {
117+
// cdn.example.com is in our test allowlist
118+
var isAllowed = com.tns.Runtime.isRemoteUrlAllowed("https://cdn.example.com/modules/utils.js");
119+
expect(isAllowed).toBe(true);
120+
});
121+
122+
it("should allow non-allowlisted URLs in debug mode", function() {
123+
// In debug mode, all URLs should be allowed even if not in allowlist
124+
var isAllowed = com.tns.Runtime.isRemoteUrlAllowed("https://unknown-domain.com/evil.js");
125+
// In debug mode, this returns true because debug bypasses allowlist
126+
expect(isAllowed).toBe(true);
127+
});
128+
});
129+
130+
describe("Static Import HTTP Loading", function() {
131+
132+
it("should attempt to load HTTP module in debug mode", function(done) {
133+
// Use a valid but unreachable URL to test the HTTP loading path
134+
// In debug mode, security check passes, then network fails
135+
import("http://10.255.255.1:5173/nonexistent-module.js").then(function(module) {
136+
// Unexpected success - but OK if it happens
137+
expect(module).toBeDefined();
138+
done();
139+
}).catch(function(error) {
140+
// Should be network error, not security error
141+
var message = error.message || String(error);
142+
expect(message).not.toContain("not allowed in production");
143+
expect(message).not.toContain("security");
144+
// Network errors contain phrases like "fetch", "network", "connect", etc
145+
done();
146+
});
147+
});
148+
});
149+
150+
describe("Dynamic Import HTTP Loading", function() {
151+
// Test dynamic imports (ImportModuleDynamicallyCallback path)
152+
153+
it("should attempt to load HTTPS module dynamically in debug mode", function(done) {
154+
var url = "https://10.255.255.1:5173/dynamic-module.js";
155+
156+
import(url).then(function(module) {
157+
expect(module).toBeDefined();
158+
done();
159+
}).catch(function(error) {
160+
var message = error.message || String(error);
161+
// In debug mode, should NOT be a security error
162+
expect(message).not.toContain("not allowed in production");
163+
expect(message).not.toContain("remoteModuleAllowlist");
164+
done();
165+
});
166+
});
167+
});
168+
});

test-app/runtime/src/main/cpp/DevFlags.cpp

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
#include "JEnv.h"
44
#include <atomic>
55
#include <mutex>
6+
#include <vector>
7+
#include <string>
68

79
namespace tns {
810

@@ -35,4 +37,105 @@ bool IsScriptLoadingLogEnabled() {
3537
return cached.load(std::memory_order_acquire) == 1;
3638
}
3739

40+
// Security config
41+
42+
static std::once_flag s_securityConfigInitFlag;
43+
static bool s_allowRemoteModules = false;
44+
static std::vector<std::string> s_remoteModuleAllowlist;
45+
static bool s_isDebuggable = false;
46+
47+
// Helper to check if a URL starts with a given prefix
48+
static bool UrlStartsWith(const std::string& url, const std::string& prefix) {
49+
if (prefix.size() > url.size()) return false;
50+
return url.compare(0, prefix.size(), prefix) == 0;
51+
}
52+
53+
void InitializeSecurityConfig() {
54+
std::call_once(s_securityConfigInitFlag, []() {
55+
try {
56+
JEnv env;
57+
jclass runtimeClass = env.FindClass("com/tns/Runtime");
58+
if (runtimeClass == nullptr) {
59+
return;
60+
}
61+
62+
// Check isDebuggable first
63+
jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z");
64+
if (isDebuggableMid != nullptr) {
65+
jboolean res = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid);
66+
s_isDebuggable = (res == JNI_TRUE);
67+
}
68+
69+
// If debuggable, we don't need to check further - always allow
70+
if (s_isDebuggable) {
71+
s_allowRemoteModules = true;
72+
return;
73+
}
74+
75+
// Check isRemoteModulesAllowed
76+
jmethodID allowRemoteMid = env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z");
77+
if (allowRemoteMid != nullptr) {
78+
jboolean res = env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid);
79+
s_allowRemoteModules = (res == JNI_TRUE);
80+
}
81+
82+
// Get the allowlist
83+
jmethodID getAllowlistMid = env.GetStaticMethodID(runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;");
84+
if (getAllowlistMid != nullptr) {
85+
jobjectArray allowlistArray = (jobjectArray)env.CallStaticObjectMethod(runtimeClass, getAllowlistMid);
86+
if (allowlistArray != nullptr) {
87+
jsize len = env.GetArrayLength(allowlistArray);
88+
for (jsize i = 0; i < len; i++) {
89+
jstring jstr = (jstring)env.GetObjectArrayElement(allowlistArray, i);
90+
if (jstr != nullptr) {
91+
const char* str = env.GetStringUTFChars(jstr, nullptr);
92+
if (str != nullptr) {
93+
s_remoteModuleAllowlist.push_back(std::string(str));
94+
env.ReleaseStringUTFChars(jstr, str);
95+
}
96+
env.DeleteLocalRef(jstr);
97+
}
98+
}
99+
env.DeleteLocalRef(allowlistArray);
100+
}
101+
}
102+
} catch (...) {
103+
// Keep defaults (remote modules disabled)
104+
}
105+
});
106+
}
107+
108+
bool IsRemoteModulesAllowed() {
109+
InitializeSecurityConfig();
110+
return s_allowRemoteModules || s_isDebuggable;
111+
}
112+
113+
bool IsRemoteUrlAllowed(const std::string& url) {
114+
InitializeSecurityConfig();
115+
116+
// Debug mode always allows all URLs
117+
if (s_isDebuggable) {
118+
return true;
119+
}
120+
121+
// Production: first check if remote modules are allowed at all
122+
if (!s_allowRemoteModules) {
123+
return false;
124+
}
125+
126+
// If no allowlist is configured, allow all URLs (user explicitly enabled remote modules)
127+
if (s_remoteModuleAllowlist.empty()) {
128+
return true;
129+
}
130+
131+
// Check if URL matches any allowlist prefix
132+
for (const std::string& prefix : s_remoteModuleAllowlist) {
133+
if (UrlStartsWith(url, prefix)) {
134+
return true;
135+
}
136+
}
137+
138+
return false;
139+
}
140+
38141
} // namespace tns
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,24 @@
11
// DevFlags.h
22
#pragma once
33

4+
#include <string>
5+
46
namespace tns {
57

68
// Fast cached flag: whether to log script loading diagnostics.
79
// First call queries Java once; subsequent calls are atomic loads only.
810
bool IsScriptLoadingLogEnabled();
911

12+
// Security config
13+
14+
// "security.allowRemoteModules" from nativescript.config
15+
bool IsRemoteModulesAllowed();
16+
17+
// "security.remoteModuleAllowlist" array from nativescript.config
18+
// If no allowlist is configured but allowRemoteModules is true, all URLs are allowed.
19+
bool IsRemoteUrlAllowed(const std::string& url);
20+
21+
// Init security configuration
22+
void InitializeSecurityConfig();
23+
1024
}

test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,21 @@ std::string GetApplicationPath();
2626

2727
// Logging flag now provided via DevFlags for fast cached access
2828

29+
// Security gate
30+
// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules and security.remoteModuleAllowlist
31+
static inline bool IsHttpUrlAllowedForLoading(const std::string& url) {
32+
return IsRemoteUrlAllowed(url);
33+
}
34+
35+
// Helper to create a security error message for blocked remote modules
36+
static std::string GetRemoteModuleBlockedMessage(const std::string& url) {
37+
if (!IsRemoteModulesAllowed()) {
38+
return "Remote ES modules are not allowed in production. URL: " + url +
39+
". Enable via security.allowRemoteModules in nativescript.config.ts";
40+
}
41+
return "Remote URL not in security.remoteModuleAllowlist: " + url;
42+
}
43+
2944
// Diagnostic helper: emit detailed V8 compile error info for HTTP ESM sources.
3045
static void LogHttpCompileDiagnostics(v8::Isolate* isolate,
3146
v8::Local<v8::Context> context,
@@ -374,6 +389,16 @@ v8::MaybeLocal<v8::Module> ResolveModuleCallback(v8::Local<v8::Context> context,
374389

375390
// HTTP(S) ESM support: resolve, fetch and compile from dev server
376391
if (spec.rfind("http://", 0) == 0 || spec.rfind("https://", 0) == 0) {
392+
// Security gate: check if remote module loading is allowed
393+
if (!IsHttpUrlAllowedForLoading(spec)) {
394+
std::string msg = GetRemoteModuleBlockedMessage(spec);
395+
if (IsScriptLoadingLogEnabled()) {
396+
DEBUG_WRITE("[http-esm][security][blocked] %s", msg.c_str());
397+
}
398+
isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg)));
399+
return v8::MaybeLocal<v8::Module>();
400+
}
401+
377402
std::string canonical = tns::CanonicalizeHttpUrlKey(spec);
378403
if (IsScriptLoadingLogEnabled()) {
379404
DEBUG_WRITE("[http-esm][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str());
@@ -869,6 +894,16 @@ v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(
869894

870895
// Handle HTTP(S) dynamic import directly
871896
if (!spec.empty() && isHttpLike(spec)) {
897+
// Security gate: check if remote module loading is allowed
898+
if (!IsHttpUrlAllowedForLoading(spec)) {
899+
std::string msg = GetRemoteModuleBlockedMessage(spec);
900+
if (IsScriptLoadingLogEnabled()) {
901+
DEBUG_WRITE("[http-esm][dyn][security][blocked] %s", msg.c_str());
902+
}
903+
resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))).Check();
904+
return scope.Escape(resolver->GetPromise());
905+
}
906+
872907
std::string canonical = tns::CanonicalizeHttpUrlKey(spec);
873908
if (IsScriptLoadingLogEnabled()) {
874909
DEBUG_WRITE("[http-esm][dyn][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str());

0 commit comments

Comments
 (0)