Skip to content

Commit 0abc979

Browse files
committed
feat: remote module security
1 parent 73332e1 commit 0abc979

6 files changed

Lines changed: 401 additions & 3 deletions

File tree

NativeScript/runtime/DevFlags.h

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
#pragma once
22

3+
#include <string>
4+
35
// Centralized development/runtime flags helpers usable across runtime sources.
46
// These read from app package.json via Runtime::GetAppConfigValue and other
57
// runtime configuration to determine behavior of dev features.
@@ -10,4 +12,17 @@ namespace tns {
1012
// Controlled by package.json setting: "logScriptLoading": true|false
1113
bool IsScriptLoadingLogEnabled();
1214

15+
// Security config
16+
17+
// In debug mode (RuntimeConfig.IsDebug): always returns true.
18+
// checks "security.allowRemoteModules" from nativescript.config.
19+
bool IsRemoteModulesAllowed();
20+
21+
// "security.remoteModuleAllowlist" array from nativescript.config.
22+
// If no allowlist is configured but allowRemoteModules is true, all URLs are allowed.
23+
bool IsRemoteUrlAllowed(const std::string& url);
24+
25+
// Init security configuration
26+
void InitializeSecurityConfig();
27+
1328
} // namespace tns

NativeScript/runtime/DevFlags.mm

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

79
namespace tns {
810

@@ -11,4 +13,85 @@ bool IsScriptLoadingLogEnabled() {
1113
return value ? [value boolValue] : false;
1214
}
1315

16+
// Security config
17+
18+
static std::once_flag s_securityConfigInitFlag;
19+
static bool s_allowRemoteModules = false;
20+
static std::vector<std::string> s_remoteModuleAllowlist;
21+
22+
// Helper to check if a URL starts with a given prefix
23+
static bool UrlStartsWith(const std::string& url, const std::string& prefix) {
24+
if (prefix.size() > url.size()) return false;
25+
return url.compare(0, prefix.size(), prefix) == 0;
26+
}
27+
28+
void InitializeSecurityConfig() {
29+
std::call_once(s_securityConfigInitFlag, []() {
30+
@autoreleasepool {
31+
// Get the security configuration object
32+
id securityValue = Runtime::GetAppConfigValue("security");
33+
if (!securityValue || ![securityValue isKindOfClass:[NSDictionary class]]) {
34+
// No security config: defaults remain (remote modules disabled in production)
35+
return;
36+
}
37+
38+
NSDictionary* security = (NSDictionary*)securityValue;
39+
40+
// Check allowRemoteModules
41+
id allowRemote = security[@"allowRemoteModules"];
42+
if (allowRemote && [allowRemote respondsToSelector:@selector(boolValue)]) {
43+
s_allowRemoteModules = [allowRemote boolValue];
44+
}
45+
46+
// Parse remoteModuleAllowlist
47+
id allowlist = security[@"remoteModuleAllowlist"];
48+
if (allowlist && [allowlist isKindOfClass:[NSArray class]]) {
49+
NSArray* list = (NSArray*)allowlist;
50+
for (id item in list) {
51+
if ([item isKindOfClass:[NSString class]]) {
52+
NSString* str = (NSString*)item;
53+
if (str.length > 0) {
54+
s_remoteModuleAllowlist.push_back(std::string([str UTF8String]));
55+
}
56+
}
57+
}
58+
}
59+
}
60+
});
61+
}
62+
63+
bool IsRemoteModulesAllowed() {
64+
if (RuntimeConfig.IsDebug) {
65+
return true;
66+
}
67+
68+
InitializeSecurityConfig();
69+
return s_allowRemoteModules;
70+
}
71+
72+
bool IsRemoteUrlAllowed(const std::string& url) {
73+
if (RuntimeConfig.IsDebug) {
74+
return true;
75+
}
76+
77+
InitializeSecurityConfig();
78+
if (!s_allowRemoteModules) {
79+
return false;
80+
}
81+
82+
// If no allowlist is configured, allow all URLs (user explicitly enabled remote modules)
83+
if (s_remoteModuleAllowlist.empty()) {
84+
return true;
85+
}
86+
87+
// Check if URL matches any allowlist prefix
88+
for (const std::string& prefix : s_remoteModuleAllowlist) {
89+
if (UrlStartsWith(url, prefix)) {
90+
return true;
91+
}
92+
}
93+
94+
return false;
95+
}
96+
1497
} // namespace tns

NativeScript/runtime/ModuleInternalCallbacks.mm

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -96,13 +96,28 @@ static inline bool EndsWith(const std::string& value, const std::string& suffix)
9696
return std::equal(suffix.rbegin(), suffix.rend(), value.rbegin());
9797
}
9898

99-
// Dev-only HTTP ESM loader helpers
99+
// HTTP ESM loader helpers
100100

101101
static inline bool StartsWith(const std::string& s, const char* prefix) {
102102
size_t n = strlen(prefix);
103103
return s.size() >= n && s.compare(0, n, prefix) == 0;
104104
}
105105

106+
// Security gate
107+
// In debug mode, all URLs are allowed. In production, checks security.allowRemoteModules and security.remoteModuleAllowlist
108+
static inline bool IsHttpUrlAllowedForLoading(const std::string& url) {
109+
return IsRemoteUrlAllowed(url);
110+
}
111+
112+
// Helper to create a security error message for blocked remote modules
113+
static std::string GetRemoteModuleBlockedMessage(const std::string& url) {
114+
if (!IsRemoteModulesAllowed()) {
115+
return "Remote ES modules are not allowed in production. URL: " + url +
116+
". Enable via security.allowRemoteModules in nativescript.config";
117+
}
118+
return "Remote URL not in security.remoteModuleAllowlist: " + url;
119+
}
120+
106121

107122
static v8::MaybeLocal<v8::Module> CompileModuleFromSource(v8::Isolate* isolate, v8::Local<v8::Context> context,
108123
const std::string& code, const std::string& urlStr) {
@@ -725,8 +740,18 @@ static bool IsDocumentsPath(const std::string& path) {
725740

726741
// ── Early absolute-HTTP fast path ─────────────────────────────
727742
// If the specifier itself is an absolute HTTP(S) URL, resolve it immediately via
728-
// the HTTP dev loader and return before any filesystem candidate logic runs.
743+
// the HTTP loader and return before any filesystem candidate logic runs.
729744
if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) {
745+
// Security check: block if remote modules not allowed
746+
if (!IsHttpUrlAllowedForLoading(spec)) {
747+
std::string msg = GetRemoteModuleBlockedMessage(spec);
748+
if (IsScriptLoadingLogEnabled()) {
749+
Log(@"[resolver][security] blocked remote module: %s", spec.c_str());
750+
}
751+
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str())));
752+
return v8::MaybeLocal<v8::Module>();
753+
}
754+
730755
std::string key = CanonicalizeHttpUrlKey(spec);
731756
// Added instrumentation for unified phase logging
732757
Log(@"[http-esm][compile][begin] %s", key.c_str());
@@ -825,6 +850,7 @@ static bool IsDocumentsPath(const std::string& path) {
825850
// ("./" or "../") or root-absolute ("/") specifiers should resolve against the
826851
// referrer's URL, not the local filesystem. Mirror browser behavior by using NSURL
827852
// to construct the absolute URL, then return an HTTP-loaded module immediately.
853+
// Security: Gated by IsHttpUrlAllowedForLoading.
828854
bool referrerIsHttp = (!referrerPath.empty() && (StartsWith(referrerPath, "http://") || StartsWith(referrerPath, "https://")));
829855
bool specIsRootAbs = !spec.empty() && spec[0] == '/';
830856
if (referrerIsHttp && (specIsRelative || specIsRootAbs)) {
@@ -845,6 +871,16 @@ static bool IsDocumentsPath(const std::string& path) {
845871
}
846872
}
847873
if (!resolvedHttp.empty() && (StartsWith(resolvedHttp, "http://") || StartsWith(resolvedHttp, "https://"))) {
874+
// Security check: block if remote modules not allowed
875+
if (!IsHttpUrlAllowedForLoading(resolvedHttp)) {
876+
std::string msg = GetRemoteModuleBlockedMessage(resolvedHttp);
877+
if (IsScriptLoadingLogEnabled()) {
878+
Log(@"[resolver][security] blocked remote module (rel): %s", resolvedHttp.c_str());
879+
}
880+
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str())));
881+
return v8::MaybeLocal<v8::Module>();
882+
}
883+
848884
if (IsScriptLoadingLogEnabled()) {
849885
Log(@"[resolver][http-rel] base=%s spec=%s -> %s", referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str());
850886
}
@@ -1010,6 +1046,16 @@ static bool IsDocumentsPath(const std::string& path) {
10101046

10111047
// If the specifier is an HTTP(S) URL, fetch via HTTP loader and return
10121048
if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) {
1049+
// Security check: block if remote modules not allowed
1050+
if (!IsHttpUrlAllowedForLoading(spec)) {
1051+
std::string msg = GetRemoteModuleBlockedMessage(spec);
1052+
if (IsScriptLoadingLogEnabled()) {
1053+
Log(@"[resolver][security] blocked remote module: %s", spec.c_str());
1054+
}
1055+
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str())));
1056+
return v8::MaybeLocal<v8::Module>();
1057+
}
1058+
10131059
std::string key = CanonicalizeHttpUrlKey(spec);
10141060
if (IsScriptLoadingLogEnabled()) {
10151061
Log(@"[http-esm][compile][begin] %s", key.c_str());
@@ -1095,6 +1141,7 @@ static bool IsDocumentsPath(const std::string& path) {
10951141

10961142
// If a candidate accidentally embeds a collapsed HTTP URL like '/app/http:/host/...',
10971143
// reconstruct the HTTP URL and resolve via the HTTP loader instead of touching the filesystem.
1144+
// Security: Gated by IsHttpUrlAllowedForLoading.
10981145
auto rerouteHttpIfEmbedded = [&](const std::string& p) -> bool {
10991146
size_t pos1 = p.find("/http:/");
11001147
size_t pos2 = p.find("/https:/");
@@ -1108,6 +1155,17 @@ static bool IsDocumentsPath(const std::string& path) {
11081155
tail.insert(6, "/");
11091156
}
11101157
if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) return false;
1158+
1159+
// Security check: block if remote modules not allowed
1160+
if (!IsHttpUrlAllowedForLoading(tail)) {
1161+
if (IsScriptLoadingLogEnabled()) {
1162+
Log(@"[resolver][security] blocked embedded remote module: %s", tail.c_str());
1163+
}
1164+
std::string msg = GetRemoteModuleBlockedMessage(tail);
1165+
isolate->ThrowException(v8::Exception::Error(tns::ToV8String(isolate, msg.c_str())));
1166+
return false;
1167+
}
1168+
11111169
if (IsScriptLoadingLogEnabled()) { Log(@"[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); }
11121170
std::string key = CanonicalizeHttpUrlKey(tail);
11131171
auto itExisting = g_moduleRegistry.find(key);
@@ -1913,6 +1971,16 @@ static bool IsDocumentsPath(const std::string& path) {
19131971

19141972
// If spec is an HTTP(S) URL, try HTTP fetch+compile directly
19151973
if (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || StartsWith(normalizedSpec, "https://"))) {
1974+
// Security check: block if remote modules not allowed
1975+
if (!IsHttpUrlAllowedForLoading(normalizedSpec)) {
1976+
std::string msg = GetRemoteModuleBlockedMessage(normalizedSpec);
1977+
if (IsScriptLoadingLogEnabled()) {
1978+
Log(@"[dyn-import][security] blocked remote module: %s", normalizedSpec.c_str());
1979+
}
1980+
resolver->Reject(context, v8::Exception::Error(tns::ToV8String(isolate, msg.c_str()))).FromMaybe(false);
1981+
return scope.Escape(resolver->GetPromise());
1982+
}
1983+
19161984
if (IsScriptLoadingLogEnabled()) {
19171985
Log(@"[dyn-import][http-loader] trying URL %s", normalizedSpec.c_str());
19181986
}

TestRunner/app/package.json

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,11 @@
11
{
2-
"main": "index"
2+
"main": "index",
3+
"security": {
4+
"allowRemoteModules": true,
5+
"remoteModuleAllowlist": [
6+
"https://esm.sh/",
7+
"https://cdn.example.com/modules/",
8+
"https://unpkg.com/"
9+
]
10+
}
311
}

0 commit comments

Comments
 (0)