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
9 changes: 8 additions & 1 deletion test-app/app/src/main/assets/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,12 @@
"enableLineBreakpoints": false,
"enableMultithreadedJavascript": true
},
"discardUncaughtJsExceptions": false
"discardUncaughtJsExceptions": false,
"security": {
"allowRemoteModules": true,
"remoteModuleAllowlist": [
"https://cdn.example.com/modules/",
"https://esm.sh/"
]
}
}
168 changes: 168 additions & 0 deletions test-app/app/src/main/assets/app/tests/testRemoteModuleSecurity.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
//
// Security configuration
// {
// "security": {
// "allowRemoteModules": true, // Enable remote module loading in production
// "remoteModuleAllowlist": [ // Optional: restrict to specific URL prefixes
// "https://cdn.example.com/modules/",
// "https://esm.sh/"
// ]
// }
// }
//
// Behavior:
// - Debug mode: Remote modules always allowed
// - Production mode: Requires security.allowRemoteModules = true
// - With allowlist: Only URLs matching a prefix in remoteModuleAllowlist are allowed

describe("Remote Module Security", function() {

describe("Debug Mode Behavior", function() {

it("should allow HTTP module imports in debug mode", function(done) {
// This test uses a known unreachable IP to trigger the HTTP loading path
// In debug mode, the security check passes and we get a network error
// (not a security error)
import("http://192.0.2.1:5173/test-module.js").then(function(module) {
// If we somehow succeed, that's fine too
expect(module).toBeDefined();
done();
}).catch(function(error) {
// Should fail with a network/timeout error, NOT a security error
var message = error.message || String(error);
// In debug mode, we should NOT see security-related error messages
expect(message).not.toContain("not allowed in production");
expect(message).not.toContain("remoteModuleAllowlist");
done();
});
});

it("should allow HTTPS module imports in debug mode", function(done) {
// Test HTTPS URL - should be allowed in debug mode
import("https://192.0.2.1:5173/test-module.js").then(function(module) {
expect(module).toBeDefined();
done();
}).catch(function(error) {
var message = error.message || String(error);
// Should NOT be a security error in debug mode
expect(message).not.toContain("not allowed in production");
expect(message).not.toContain("remoteModuleAllowlist");
done();
});
});
});

describe("Security Configuration", function() {

it("should have security configuration in package.json", function() {
var context = com.tns.Runtime.getCurrentRuntime().getContext();
var assetManager = context.getAssets();

try {
var inputStream = assetManager.open("app/package.json");
var reader = new java.io.BufferedReader(new java.io.InputStreamReader(inputStream));
var sb = new java.lang.StringBuilder();
var line;

while ((line = reader.readLine()) !== null) {
sb.append(line);
}
reader.close();

var jsonString = sb.toString();
var config = JSON.parse(jsonString);

// Verify security config structure
expect(config.security).toBeDefined();
expect(typeof config.security.allowRemoteModules).toBe("boolean");
expect(Array.isArray(config.security.remoteModuleAllowlist)).toBe(true);
} catch (e) {
fail("Failed to read package.json: " + e.message);
}
});

it("should parse security allowRemoteModules from package.json", function() {
var allowed = com.tns.Runtime.getSecurityAllowRemoteModules();
expect(typeof allowed).toBe("boolean");
expect(allowed).toBe(true); // Matches our test package.json config
});

it("should parse security remoteModuleAllowlist from package.json", function() {
var allowlist = com.tns.Runtime.getSecurityRemoteModuleAllowlist();
expect(allowlist).not.toBeNull();
expect(Array.isArray(allowlist)).toBe(true);
expect(allowlist.length).toBeGreaterThan(0);

// Verify our test allowlist entries are present
var hasEsmSh = false;
var hasCdn = false;
for (var i = 0; i < allowlist.length; i++) {
if (allowlist[i].indexOf("esm.sh") !== -1) hasEsmSh = true;
if (allowlist[i].indexOf("cdn.example.com") !== -1) hasCdn = true;
}
expect(hasEsmSh).toBe(true);
expect(hasCdn).toBe(true);
});
});

describe("URL Allowlist Matching", function() {

it("should match URLs in the allowlist (esm.sh)", function() {
// esm.sh is in our test allowlist
var isAllowed = com.tns.Runtime.isRemoteUrlAllowed("https://esm.sh/lodash");
expect(isAllowed).toBe(true);
});

it("should match URLs in the allowlist (cdn.example.com)", function() {
// cdn.example.com is in our test allowlist
var isAllowed = com.tns.Runtime.isRemoteUrlAllowed("https://cdn.example.com/modules/utils.js");
expect(isAllowed).toBe(true);
});

it("should allow non-allowlisted URLs in debug mode", function() {
// In debug mode, all URLs should be allowed even if not in allowlist
var isAllowed = com.tns.Runtime.isRemoteUrlAllowed("https://unknown-domain.com/evil.js");
// In debug mode, this returns true because debug bypasses allowlist
expect(isAllowed).toBe(true);
});
});

describe("Static Import HTTP Loading", function() {

it("should attempt to load HTTP module in debug mode", function(done) {
// Use a valid but unreachable URL to test the HTTP loading path
// In debug mode, security check passes, then network fails
import("http://10.255.255.1:5173/nonexistent-module.js").then(function(module) {
// Unexpected success - but OK if it happens
expect(module).toBeDefined();
done();
}).catch(function(error) {
// Should be network error, not security error
var message = error.message || String(error);
expect(message).not.toContain("not allowed in production");
expect(message).not.toContain("security");
// Network errors contain phrases like "fetch", "network", "connect", etc
done();
});
});
});

describe("Dynamic Import HTTP Loading", function() {
// Test dynamic imports (ImportModuleDynamicallyCallback path)

it("should attempt to load HTTPS module dynamically in debug mode", function(done) {
var url = "https://10.255.255.1:5173/dynamic-module.js";

import(url).then(function(module) {
expect(module).toBeDefined();
done();
}).catch(function(error) {
var message = error.message || String(error);
// In debug mode, should NOT be a security error
expect(message).not.toContain("not allowed in production");
expect(message).not.toContain("remoteModuleAllowlist");
done();
});
});
});
});
103 changes: 103 additions & 0 deletions test-app/runtime/src/main/cpp/DevFlags.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
#include "JEnv.h"
#include <atomic>
#include <mutex>
#include <vector>
#include <string>

namespace tns {

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

// Security config

static std::once_flag s_securityConfigInitFlag;
static bool s_allowRemoteModules = false;
static std::vector<std::string> s_remoteModuleAllowlist;
static bool s_isDebuggable = false;

// Helper to check if a URL starts with a given prefix
static bool UrlStartsWith(const std::string& url, const std::string& prefix) {
if (prefix.size() > url.size()) return false;
return url.compare(0, prefix.size(), prefix) == 0;
}

void InitializeSecurityConfig() {
std::call_once(s_securityConfigInitFlag, []() {
try {
JEnv env;
jclass runtimeClass = env.FindClass("com/tns/Runtime");
if (runtimeClass == nullptr) {
return;
}

// Check isDebuggable first
jmethodID isDebuggableMid = env.GetStaticMethodID(runtimeClass, "isDebuggable", "()Z");
if (isDebuggableMid != nullptr) {
jboolean res = env.CallStaticBooleanMethod(runtimeClass, isDebuggableMid);
s_isDebuggable = (res == JNI_TRUE);
}

// If debuggable, we don't need to check further - always allow
if (s_isDebuggable) {
s_allowRemoteModules = true;
return;
}

// Check isRemoteModulesAllowed
jmethodID allowRemoteMid = env.GetStaticMethodID(runtimeClass, "isRemoteModulesAllowed", "()Z");
if (allowRemoteMid != nullptr) {
jboolean res = env.CallStaticBooleanMethod(runtimeClass, allowRemoteMid);
s_allowRemoteModules = (res == JNI_TRUE);
}

// Get the allowlist
jmethodID getAllowlistMid = env.GetStaticMethodID(runtimeClass, "getRemoteModuleAllowlist", "()[Ljava/lang/String;");
if (getAllowlistMid != nullptr) {
jobjectArray allowlistArray = (jobjectArray)env.CallStaticObjectMethod(runtimeClass, getAllowlistMid);
if (allowlistArray != nullptr) {
jsize len = env.GetArrayLength(allowlistArray);
for (jsize i = 0; i < len; i++) {
jstring jstr = (jstring)env.GetObjectArrayElement(allowlistArray, i);
if (jstr != nullptr) {
const char* str = env.GetStringUTFChars(jstr, nullptr);
if (str != nullptr) {
s_remoteModuleAllowlist.push_back(std::string(str));
env.ReleaseStringUTFChars(jstr, str);
}
env.DeleteLocalRef(jstr);
}
}
env.DeleteLocalRef(allowlistArray);
}
}
} catch (...) {
// Keep defaults (remote modules disabled)
}
});
}

bool IsRemoteModulesAllowed() {
InitializeSecurityConfig();
return s_allowRemoteModules || s_isDebuggable;
}

bool IsRemoteUrlAllowed(const std::string& url) {
InitializeSecurityConfig();

// Debug mode always allows all URLs
if (s_isDebuggable) {
return true;
}

// Production: first check if remote modules are allowed at all
if (!s_allowRemoteModules) {
return false;
}

// If no allowlist is configured, allow all URLs (user explicitly enabled remote modules)
if (s_remoteModuleAllowlist.empty()) {
return true;
}

// Check if URL matches any allowlist prefix
for (const std::string& prefix : s_remoteModuleAllowlist) {
if (UrlStartsWith(url, prefix)) {
return true;
}
}

return false;
}

} // namespace tns
14 changes: 14 additions & 0 deletions test-app/runtime/src/main/cpp/DevFlags.h
Original file line number Diff line number Diff line change
@@ -1,10 +1,24 @@
// DevFlags.h
#pragma once

#include <string>

namespace tns {

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

// Security config

// "security.allowRemoteModules" from nativescript.config
bool IsRemoteModulesAllowed();

// "security.remoteModuleAllowlist" array from nativescript.config
// If no allowlist is configured but allowRemoteModules is true, all URLs are allowed.
bool IsRemoteUrlAllowed(const std::string& url);

// Init security configuration
void InitializeSecurityConfig();

}
35 changes: 35 additions & 0 deletions test-app/runtime/src/main/cpp/ModuleInternalCallbacks.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ std::string GetApplicationPath();

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

// Security gate
// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules and security.remoteModuleAllowlist
static inline bool IsHttpUrlAllowedForLoading(const std::string& url) {
return IsRemoteUrlAllowed(url);
}

// Helper to create a security error message for blocked remote modules
static std::string GetRemoteModuleBlockedMessage(const std::string& url) {
if (!IsRemoteModulesAllowed()) {
return "Remote ES modules are not allowed in production. URL: " + url +
". Enable via security.allowRemoteModules in nativescript.config.ts";
}
return "Remote URL not in security.remoteModuleAllowlist: " + url;
}

// Diagnostic helper: emit detailed V8 compile error info for HTTP ESM sources.
static void LogHttpCompileDiagnostics(v8::Isolate* isolate,
v8::Local<v8::Context> context,
Expand Down Expand Up @@ -374,6 +389,16 @@ v8::MaybeLocal<v8::Module> ResolveModuleCallback(v8::Local<v8::Context> context,

// HTTP(S) ESM support: resolve, fetch and compile from dev server
if (spec.rfind("http://", 0) == 0 || spec.rfind("https://", 0) == 0) {
// Security gate: check if remote module loading is allowed
if (!IsHttpUrlAllowedForLoading(spec)) {
std::string msg = GetRemoteModuleBlockedMessage(spec);
if (IsScriptLoadingLogEnabled()) {
DEBUG_WRITE("[http-esm][security][blocked] %s", msg.c_str());
}
isolate->ThrowException(v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg)));
return v8::MaybeLocal<v8::Module>();
}

std::string canonical = tns::CanonicalizeHttpUrlKey(spec);
if (IsScriptLoadingLogEnabled()) {
DEBUG_WRITE("[http-esm][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str());
Expand Down Expand Up @@ -869,6 +894,16 @@ v8::MaybeLocal<v8::Promise> ImportModuleDynamicallyCallback(

// Handle HTTP(S) dynamic import directly
if (!spec.empty() && isHttpLike(spec)) {
// Security gate: check if remote module loading is allowed
if (!IsHttpUrlAllowedForLoading(spec)) {
std::string msg = GetRemoteModuleBlockedMessage(spec);
if (IsScriptLoadingLogEnabled()) {
DEBUG_WRITE("[http-esm][dyn][security][blocked] %s", msg.c_str());
}
resolver->Reject(context, v8::Exception::Error(ArgConverter::ConvertToV8String(isolate, msg))).Check();
return scope.Escape(resolver->GetPromise());
}

std::string canonical = tns::CanonicalizeHttpUrlKey(spec);
if (IsScriptLoadingLogEnabled()) {
DEBUG_WRITE("[http-esm][dyn][resolve] spec=%s canonical=%s", spec.c_str(), canonical.c_str());
Expand Down
Loading
Loading