Skip to content

Comments

Scripting security harness#183

Merged
Segfaultd merged 10 commits intodevelopfrom
scripting/security-harness
Feb 11, 2026
Merged

Scripting security harness#183
Segfaultd merged 10 commits intodevelopfrom
scripting/security-harness

Conversation

@Segfaultd
Copy link
Member

@Segfaultd Segfaultd commented Feb 11, 2026

Client side scripting shouldn't use nodejs for security reason, but only raw V8 integration.

This PR fixes that.

Summary by CodeRabbit

  • New Features

    • Standalone V8 scripting engine: file execution, CommonJS-like module loading, sandboxed require(), timers (setTimeout/setInterval/clear), microtask support, and improved error reporting.
    • Public runtime controls: ExecuteFile, Tick, Reset, and accessors to inspect the runtime/framework object.
  • Chores

    • Migrated client scripting from a Node.js-based runtime to the new V8 implementation; build updated to expose the new engine.

@coderabbitai
Copy link

coderabbitai bot commented Feb 11, 2026

Walkthrough

Replaces Node-based scripting with a standalone V8Engine: adds V8 engine sources, callbacks, and CMake object target; migrates client scripting module to use V8Engine; moves engine execution/SDK/helpers into base Engine; updates module loading, timers, and promise handling paths.

Changes

Cohort / File(s) Summary
Build Configuration
code/framework/CMakeLists.txt
Adds FrameworkV8Engine OBJECT library (sources, include dirs, link deps v8/v8pp/spdlog, C++20), wires its objects into Framework, and extends FrameworkServer link deps (adds MafiaHubServices, libsig).
Client Scripting Module
code/framework/src/integrations/client/scripting/module.h, code/framework/src/integrations/client/scripting/module.cpp
Replaces NodeEngine with V8Engine (_nodeEngine_engine), updates GetEngine() return type, adds Reset(), sets module root before Init, and updates init/shutdown/tick/resource flows to use V8 APIs.
Engine Base & Helpers
code/framework/src/scripting/engine.h, code/framework/src/scripting/engine.cpp, code/framework/src/scripting/engine_helpers.h
Converts Engine methods to concrete implementations (adds Execute, InitFrameworkSDK), adds GetFrameworkObject(), and adds FormatV8Exception helper for V8 error formatting.
V8 Engine Implementation
code/framework/src/scripting/v8_engine.h, code/framework/src/scripting/v8_engine.cpp, code/framework/src/scripting/v8_engine_callbacks.h
Adds V8Engine public class and callbacks: platform/isolate/context init, globals (require, timers, queueMicrotask), CommonJS-like module loader, module cache, timers, promise rejection handling, ExecuteFile, Tick, and related public APIs/structs.
Node Engine Adjustments
code/framework/src/scripting/node_engine.h, code/framework/src/scripting/node_engine.cpp
Removes NodeEngine implementations for Execute, InitFrameworkSDK, GetFrameworkObject; switches logging usage to framework logger and includes engine_helpers.h.
Resource Manager
code/framework/src/scripting/resource/resource_manager.cpp
Removes manual require wrapper and path-escaping logic; delegates resource script execution to engine via ExecuteFile.
Logging
code/framework/src/logging/logger.h
Removed unused formatters.h include.
CI / Submodules
.github/workflows/build-test.yml, .github/workflows/update_services.yml, code/framework/src/services/src
Pins CMake version (cmakeVersion: "~3.31.0") in CI steps; updates a services submodule commit reference.

Sequence Diagrams

sequenceDiagram
    participant Client as Client Scripting Module
    participant Engine as V8Engine
    participant Platform as V8 Platform
    participant Context as V8 Context
    participant Module as Module System

    Client->>Engine: SetModuleRootPath(path)
    Client->>Engine: Init()
    Engine->>Platform: InitializePlatform()
    Engine->>Engine: CreateIsolateAndContext()
    Engine->>Context: InstallGlobals (timers, require, queueMicrotask)
    Context->>Module: Prepare module system
    Engine-->>Client: Initialized

    Client->>Engine: ExecuteFile(scriptPath)
    Engine->>Module: ResolveModulePath(requested, fromDir)
    Module->>Module: Read & compile module
    Module->>Context: Execute wrapped code
    Module-->>Engine: module.exports
    Engine-->>Client: Success / Error
Loading
sequenceDiagram
    participant Script as User Script
    participant Require as require()
    participant Engine as V8Engine
    participant Cache as Module Cache
    participant Isolate as V8 Isolate

    Script->>Require: require("mod")
    Require->>Engine: LoadModule(requested, currentDir)
    Engine->>Cache: Check cache for resolved path
    alt cached
        Cache-->>Require: cached exports
    else not cached
        Engine->>Isolate: Compile & execute module
        Isolate->>Engine: module.exports
        Engine->>Cache: Store exports
        Engine-->>Require: exports
    end
    Require-->>Script: exports
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Suggested reviewers

  • zpl-zak

Poem

🐰 I swapped Node for V8 today,
Timers tick and modules play.
A sandbox root, a tiny hop,
Microtasks queued — the stack won't stop.
Hooray, the rabbit shipped this way.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 18.18% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Scripting security harness' directly reflects the main objective of replacing Node.js with V8 for security reasons on the client side, which aligns with the primary change throughout the changeset.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch scripting/security-harness

Tip

Issue Planner is now in beta. Read the docs and try it out! Share your feedback on Discord.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Fix all issues with AI agents
In `@code/framework/src/integrations/client/scripting/module.cpp`:
- Around line 30-47: ClientScriptingModule::Init can dereference a null _engine
if called after Shutdown() (which sets _engine to nullptr); add a defensive null
check at the top of Init to avoid calling _engine->SetSDKRegisterCallback,
_engine->SetModuleRootPath or _engine->Init when _engine is null — either
recreate _engine before use if Init-after-Shutdown is a supported lifecycle, or
log an error and return false immediately (include a clear message referencing
Shutdown) so subsequent calls like SetSDKRegisterCallback, SetModuleRootPath and
_engine->Init are never invoked on a null pointer; ensure the check references
_engine, Shutdown(), Reset(), SetSDKRegisterCallback, SetModuleRootPath and
_engine->Init to locate the change.

In `@code/framework/src/scripting/v8_engine_callbacks.h`:
- Around line 21-37: ReadFileContents currently returns true without verifying
that file.read() actually read the requested number of bytes; update
ReadFileContents to check the read result (e.g., check file.fail() or that
file.gcount() == size) after file.read(outContent.data(), size), and if the read
failed or is partial, log a warning via
Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING) including filepath and byte
counts, clear or reset outContent, and return false; keep existing size checks
(kMaxModuleFileSize) and behavior otherwise.

In `@code/framework/src/scripting/v8_engine.cpp`:
- Around line 245-296: ProcessTimers currently collects raw TimerEntry* into
dueTimers which can dangle if AddTimer pushes and reallocates _timers; change
the collection to store stable identifiers or indices and look up the timer from
_timers at call time. Specifically, in V8Engine::ProcessTimers replace
std::vector<TimerEntry*> dueTimers with std::vector<size_t> dueIndices (or use
timer->id values if TimerEntry has an ID), push the index (or id) for each due
timer when scanning _timers, and when invoking the callback retrieve the timer
via _timers[index].get() (or map id→pointer) and re-check cancelled/bounds
before use; optionally also keep a local shared_ptr/unique_ptr copy or reserve
_timers capacity in AddTimer/constructor to prevent reallocation as an
additional mitigation.
- Around line 298-357: The path boundary check in V8Engine::ResolveModulePath
uses case-sensitive string comparison (resolvedStr vs rootStr) but on Windows
filesystems are case-insensitive; normalize both paths to a consistent case on
Windows before the comparison to avoid sandbox bypass. Modify the logic around
resolvedStr/rootStr (used with _options.moduleRootPath) to, when
compiling/running on Windows, lowercase (or otherwise case-normalize) both root
and resolved path strings (after weakly_canonical) and then perform the existing
prefix + boundary checks; keep the existing checks for non-Windows unchanged.
🧹 Nitpick comments (6)
code/framework/src/scripting/engine.cpp (2)

14-19: Inconsistent variable naming: use camelCase per project guidelines.

Lines 16-17 and 19 use snake_case (isolate_scope, handle_scope, context_scope) while line 21 uses camelCase (tryCatch). The coding guidelines specify camelCase for variables.

🔧 Suggested naming fix
         v8::Isolate *isolate = GetIsolate();
         v8::Locker locker(isolate);
-        v8::Isolate::Scope isolate_scope(isolate);
-        v8::HandleScope handle_scope(isolate);
+        v8::Isolate::Scope isolateScope(isolate);
+        v8::HandleScope handleScope(isolate);
         v8::Local<v8::Context> context = GetContext();
-        v8::Context::Scope context_scope(context);
+        v8::Context::Scope contextScope(context);

As per coding guidelines: "Use _ prefix for private members and camelCase naming for variables."


56-71: GetFrameworkObject() assumes V8 scopes are already active — consider documenting this precondition.

This method is public and const, but it accesses GetContext()->Global() without establishing a Locker/HandleScope. Callers must set up V8 scopes beforehand. The current call sites appear to do so, but a brief doc comment on the declaration (in engine.h line 96-99) would make this contract explicit.

code/framework/src/scripting/v8_engine_callbacks.h (1)

15-18: Nit: static is redundant on constexpr namespace-scope variables in C++20.

constexpr variables at namespace scope already have internal linkage. The static keyword is harmless but redundant.

Suggested fix
-    static constexpr std::streamsize kMaxModuleFileSize = 10 * 1024 * 1024;
+    constexpr std::streamsize kMaxModuleFileSize = 10 * 1024 * 1024;
code/framework/src/scripting/v8_engine.h (2)

78-102: Consider reducing public API surface for internal callback data.

TimerEntry, RequireData, and TimerCallbackData are public solely to be accessible from anonymous-namespace callbacks in v8_engine_callbacks.h. This exposes implementation details in the public header. A friend declaration or moving these to an internal header could tighten the interface, but this is a minor encapsulation nit given the current architecture.


122-123: _nextTimerId will wrap around to 0 after UINT32_MAX timers.

After ~4 billion timer creations, _nextTimerId++ wraps to 0. AddTimer assigns id = _nextTimerId++, so ID 0 would be assigned, and subsequent IDs could collide with still-active timers. Practically near-impossible to hit, but if you want belt-and-suspenders:

🔧 Optional: skip ID 0 and check for collisions
 uint32_t V8Engine::AddTimer(std::unique_ptr<TimerEntry> entry) {
     if (_timers.size() >= kMaxTimers) {
         Logging::GetLogger(FRAMEWORK_INNER_SCRIPTING)->warn("Timer limit ({}) reached, ignoring new timer", kMaxTimers);
         return 0;
     }
-    uint32_t id = _nextTimerId++;
+    uint32_t id = _nextTimerId++;
+    if (id == 0) id = _nextTimerId++; // skip 0 (used as "no timer" sentinel)
     entry->id = id;
     _timers.push_back(std::move(entry));
     return id;
 }
code/framework/src/scripting/v8_engine.cpp (1)

225-234: AddTimer returns 0 on limit — ensure callers treat 0 as "no timer".

SetTimerCallback (in v8_engine_callbacks.h) returns the timer ID to JavaScript via args.GetReturnValue().Set(...). When the limit is reached, ID 0 is returned. Per the Web API spec, setTimeout/setInterval should always return a positive integer. Returning 0 could confuse scripts that use if (timerId) checks, and clearTimeout(0) would be a no-op (since no timer has ID 0), so this is mostly benign — but worth being aware of.

@github-project-automation github-project-automation bot moved this to In Progress in MafiaHub Dashboard Feb 11, 2026
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@code/framework/src/integrations/client/scripting/module.cpp`:
- Around line 46-48: In Init(), avoid calling std::filesystem::absolute on an
empty _resourceCachePath because that resolves to cwd; instead check
_resourceCachePath first (from SetResourceCachePath) and if it's empty call
_engine->SetModuleRootPath("") (or log/throw a clear error) rather than
std::filesystem::absolute(_resourceCachePath). Update the code around
SetModuleRootPath to use this guard so require() is sandboxed correctly or the
missing cache path fails fast; reference Init, _resourceCachePath,
SetResourceCachePath, and SetModuleRootPath when making the change.
🧹 Nitpick comments (5)
code/framework/src/scripting/v8_engine.cpp (5)

16-17: Static platform initialization is not thread-safe.

_platformInitialized and _platform are static members accessed without synchronization. If two V8Engine instances are ever constructed concurrently (from different threads), InitializePlatform() can race — both threads see _platformInitialized == false, and v8::V8::Initialize() gets called twice, which is undefined behavior per V8 docs.

If only one engine instance exists in practice, this is not an active bug, but a std::once_flag / std::call_once would be a low-cost safeguard:

Suggested fix
-    std::unique_ptr<v8::Platform> V8Engine::_platform = nullptr;
-    bool V8Engine::_platformInitialized = false;
+    std::unique_ptr<v8::Platform> V8Engine::_platform = nullptr;
+    std::once_flag V8Engine::_platformInitFlag;

     // ...

     bool V8Engine::InitializePlatform() {
-        if (_platformInitialized) {
-            return true;
-        }
-
-        _platform = v8::platform::NewDefaultPlatform();
-        v8::V8::InitializePlatform(_platform.get());
-        v8::V8::Initialize();
-
-        _platformInitialized = true;
+        std::call_once(_platformInitFlag, []() {
+            _platform = v8::platform::NewDefaultPlatform();
+            v8::V8::InitializePlatform(_platform.get());
+            v8::V8::Initialize();
+        });
         return true;
     }

Also applies to: 91-103


53-89: Consider holding the V8 Locker while clearing persistent handles.

Lines 69–75 destroy v8::Global handles (inside _timers and _moduleCache) after the v8::Locker block at lines 58–66 has exited. While v8::Global destructors typically don't require the locker, some V8 embedder guides recommend holding it during destruction if the isolate is still alive, to avoid races with concurrent GC or platform tasks.

Moving the .clear() calls inside the existing locker block (or extending it) would be a low-risk safety improvement:

Suggested adjustment
     {
         v8::Locker locker(_isolate);
         v8::Isolate::Scope isolate_scope(_isolate);
         v8::HandleScope handle_scope(_isolate);
         v8::Local<v8::Context> context = _context.Get(_isolate);
         v8::Context::Scope context_scope(context);

         Messages::Shutdown();
+
+        // Clear persistent handles while holding the locker
+        _timers.clear();
+        _moduleCache.clear();
+        _requireDataStore.clear();
+        _context.Reset();
     }

-    // Clear all timer callbacks (prevent dangling references)
-    _timers.clear();
-
-    // Clear module cache
-    _moduleCache.clear();
-
-    // Clear require data store (frees all RequireData instances)
-    _requireDataStore.clear();
-
-    // Reset the persistent context handle
-    _context.Reset();
-
     // Dispose the isolate
     _isolate->Dispose();

227-236: Timer ID 0 serves double duty as both error sentinel and potential valid ID after wrap-around.

AddTimer returns 0 to signal "limit reached" (line 230), but _nextTimerId++ will eventually wrap to 0 after ~4 billion timers, making a successfully added timer indistinguishable from the error case. Practically unreachable in a normal session, but starting the counter at 1 would eliminate the ambiguity cheaply:

Suggested tweak
     uint32_t id = _nextTimerId++;
+    if (id == 0) id = _nextTimerId++;  // skip sentinel value
     entry->id = id;

58-66: Local variable naming uses snake_case instead of camelCase.

Variables like isolate_scope, handle_scope, and context_scope throughout v8_engine.cpp use snake_case, whereas the coding guidelines specify camelCase for variables (and module.cpp consistently uses isolateScope, handleScope, contextScope).

Example diff (apply throughout)
-        v8::Locker locker(_isolate);
-        v8::Isolate::Scope isolate_scope(_isolate);
-        v8::HandleScope handle_scope(_isolate);
+        v8::Locker locker(_isolate);
+        v8::Isolate::Scope isolateScope(_isolate);
+        v8::HandleScope handleScope(_isolate);

As per coding guidelines: "Use _ prefix for private members and camelCase naming for variables."

Also applies to: 511-514


91-103: V8 platform is initialized but never disposed.

InitializePlatform() calls v8::V8::Initialize() but there's no corresponding v8::V8::Dispose() / v8::V8::ShutdownPlatform() anywhere. For a game client that runs until process exit this is typically fine (OS reclaims resources), but it means the V8 platform is effectively process-lifetime. If clean restart of the scripting subsystem is ever needed without restarting the process, this would need to be addressed.

@Segfaultd Segfaultd merged commit b4d55e8 into develop Feb 11, 2026
4 checks passed
@Segfaultd Segfaultd deleted the scripting/security-harness branch February 11, 2026 12:14
@github-project-automation github-project-automation bot moved this from In Progress to Done in MafiaHub Dashboard Feb 11, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

2 participants