Skip to content

Commit 721ceaf

Browse files
authored
feat: remote module security (#331)
1 parent 73332e1 commit 721ceaf

File tree

7 files changed

+349
-4
lines changed

7 files changed

+349
-4
lines changed

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/HMRSupport.mm

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,16 @@ void InitializeImportMetaHot(v8::Isolate* isolate,
240240
}
241241

242242
bool HttpFetchText(const std::string& url, std::string& out, std::string& contentType, int& status) {
243+
// Security gate: check if remote module loading is allowed before any HTTP fetch.
244+
// This is the single point of enforcement for all HTTP module loading.
245+
if (!IsRemoteUrlAllowed(url)) {
246+
status = 403; // Forbidden
247+
if (IsScriptLoadingLogEnabled()) {
248+
Log(@"[http-esm][security][blocked] %s", url.c_str());
249+
}
250+
return false;
251+
}
252+
243253
@autoreleasepool {
244254
NSURL* u = [NSURL URLWithString:[NSString stringWithUTF8String:url.c_str()]];
245255
if (!u) { status = 0; return false; }

NativeScript/runtime/ModuleInternalCallbacks.mm

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -96,8 +96,6 @@ 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
100-
10199
static inline bool StartsWith(const std::string& s, const char* prefix) {
102100
size_t n = strlen(prefix);
103101
return s.size() >= n && s.compare(0, n, prefix) == 0;
@@ -725,7 +723,8 @@ static bool IsDocumentsPath(const std::string& path) {
725723

726724
// ── Early absolute-HTTP fast path ─────────────────────────────
727725
// 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.
726+
// the HTTP loader and return before any filesystem candidate logic runs.
727+
// Security: HttpFetchText gates remote module access centrally.
729728
if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) {
730729
std::string key = CanonicalizeHttpUrlKey(spec);
731730
// Added instrumentation for unified phase logging
@@ -825,6 +824,7 @@ static bool IsDocumentsPath(const std::string& path) {
825824
// ("./" or "../") or root-absolute ("/") specifiers should resolve against the
826825
// referrer's URL, not the local filesystem. Mirror browser behavior by using NSURL
827826
// to construct the absolute URL, then return an HTTP-loaded module immediately.
827+
// Security: HttpFetchText gates remote module access centrally.
828828
bool referrerIsHttp = (!referrerPath.empty() && (StartsWith(referrerPath, "http://") || StartsWith(referrerPath, "https://")));
829829
bool specIsRootAbs = !spec.empty() && spec[0] == '/';
830830
if (referrerIsHttp && (specIsRelative || specIsRootAbs)) {
@@ -845,6 +845,7 @@ static bool IsDocumentsPath(const std::string& path) {
845845
}
846846
}
847847
if (!resolvedHttp.empty() && (StartsWith(resolvedHttp, "http://") || StartsWith(resolvedHttp, "https://"))) {
848+
// Security: HttpFetchText gates remote module access centrally.
848849
if (IsScriptLoadingLogEnabled()) {
849850
Log(@"[resolver][http-rel] base=%s spec=%s -> %s", referrerPath.c_str(), spec.c_str(), resolvedHttp.c_str());
850851
}
@@ -1009,6 +1010,7 @@ static bool IsDocumentsPath(const std::string& path) {
10091010
std::string absPath;
10101011

10111012
// If the specifier is an HTTP(S) URL, fetch via HTTP loader and return
1013+
// Security: HttpFetchText gates remote module access centrally.
10121014
if (StartsWith(spec, "http://") || StartsWith(spec, "https://")) {
10131015
std::string key = CanonicalizeHttpUrlKey(spec);
10141016
if (IsScriptLoadingLogEnabled()) {
@@ -1095,6 +1097,7 @@ static bool IsDocumentsPath(const std::string& path) {
10951097

10961098
// If a candidate accidentally embeds a collapsed HTTP URL like '/app/http:/host/...',
10971099
// reconstruct the HTTP URL and resolve via the HTTP loader instead of touching the filesystem.
1100+
// Security: HttpFetchText gates remote module access centrally.
10981101
auto rerouteHttpIfEmbedded = [&](const std::string& p) -> bool {
10991102
size_t pos1 = p.find("/http:/");
11001103
size_t pos2 = p.find("/https:/");
@@ -1108,6 +1111,7 @@ static bool IsDocumentsPath(const std::string& path) {
11081111
tail.insert(6, "/");
11091112
}
11101113
if (!(StartsWith(tail, "http://") || StartsWith(tail, "https://"))) return false;
1114+
11111115
if (IsScriptLoadingLogEnabled()) { Log(@"[resolver][http-embedded] %s -> %s", p.c_str(), tail.c_str()); }
11121116
std::string key = CanonicalizeHttpUrlKey(tail);
11131117
auto itExisting = g_moduleRegistry.find(key);
@@ -1912,6 +1916,7 @@ static bool IsDocumentsPath(const std::string& path) {
19121916
}
19131917

19141918
// If spec is an HTTP(S) URL, try HTTP fetch+compile directly
1919+
// Security: HttpFetchText gates remote module access centrally.
19151920
if (!normalizedSpec.empty() && (StartsWith(normalizedSpec, "http://") || StartsWith(normalizedSpec, "https://"))) {
19161921
if (IsScriptLoadingLogEnabled()) {
19171922
Log(@"[dyn-import][http-loader] trying URL %s", normalizedSpec.c_str());

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)