unlock unlimited code runs for $79, limited time →
NOSSLETTER.techLIVE
back to OSSpedia
api-designerror-handling

ffi: keep FFI functions non-constructible

Project: nodejs/node | Language: JavaScript, C++ | Concepts: api-design, error-handling | Difficulty: mid
Link: https://github.com/nodejs/node/pull/65184

The problem

FFI (Foreign Function Interface) functions exposed by Node.js could be constructed (i.e., called with new or via Reflect.construct), which is not intended or meaningful for these function objects. This was inconsistent with the behavior of native FFI functions and could lead to confusion or misuse, especially across different platforms and invocation paths.

Why it's non-trivial

A naive fix might only update the main JavaScript wrapper, missing fallback or slow-path implementations (such as those used on AIX or big-endian systems). Additionally, in JavaScript, the difference between function expressions and concise method syntax affects whether a function is constructible. Ensuring all possible FFI function objects are non-constructible, regardless of how they're created or which code path is taken, requires careful handling in both JavaScript and C++.

The approach

1. Use concise method syntax for JavaScript FFI wrappers

Concise methods (e.g., { foo() {} }) do not have a [[Construct]] slot, so they are not constructible. The PR replaces function expressions with concise methods for all FFI wrapper creation paths.

Example change in lib/internal/ffi-shared-buffer.js:

-    wrapper = function(...args) {
+    wrapper = { invoke(...args) {
       if (args.length !== nargs) {
         throwFFIArgCountError(nargs, args.length);
       }
       // ...
       rawFn();
       return retGetter === null ? undefined : retGetter(view, 0, true);
-    };
+    } }.invoke;

What this does:
Wraps the function in an object as a concise method, then extracts the method. This ensures the resulting function is not constructible (does not have a .prototype property and cannot be used with new).

Similar changes are made throughout buildNumericWrapper and in lib/internal/ffi/fast-api.js:

-    wrapper = function(a0) {
+    wrapper = { invoke(a0) {
       // ...
       return rawFn(arg);
-    };
+    } }.invoke;

2. Ensure C++-created FFI functions are non-constructible

In src/node_ffi.cc, when creating fallback native functions (used on platforms like AIX), the PR explicitly sets the constructor behavior to throw if construction is attempted.

-    maybe_ret = Function::New(context,
-                              use_sb ? DynamicLibrary::InvokeFunctionSB
-                                     : DynamicLibrary::InvokeFunction,
-                              info->object());
+    maybe_ret = Function::New(context,
+                              use_sb ? DynamicLibrary::InvokeFunctionSB
+                                     : DynamicLibrary::InvokeFunction,
+                              info->object(),
+                              0,
+                              v8::ConstructorBehavior::kThrow);

What this does:
By passing v8::ConstructorBehavior::kThrow, the created function will throw a TypeError if someone tries to construct it with new.

Similar logic is applied to the slow-path fallback:

-      if (!Function::New(
-               context, DynamicLibrary::InvokeFunction, info->object())
+      if (!Function::New(context,
+                         DynamicLibrary::InvokeFunction,
+                         info->object(),
+                         0,
+                         v8::ConstructorBehavior::kThrow)
                .ToLocal(&slow_fn)) {
         return MaybeLocal<Function>();
       }

3. Add tests to verify FFI functions are not constructible

A new test is added to test/ffi/test-ffi-dynamic-library.js:

test('FFI functions are not constructible', () => {
  const { lib, functions } = ffi.dlopen(libraryPath, {
    add_i32: fixtureSymbols.add_i32,
    multiply_f64: fixtureSymbols.multiply_f64,
  });

  try {
    assert.strictEqual(Object.hasOwn(functions.add_i32, 'prototype'), false);
    assert.strictEqual(
      Object.hasOwn(functions.multiply_f64, 'prototype'), false);
    assert.throws(
      () => Reflect.construct(functions.add_i32, [20, 22]),
      TypeError);
    assert.throws(
      () => Reflect.construct(functions.multiply_f64, [6, 7]),
      TypeError);
  } finally {
    lib.close();
  }
});

What this does:
Checks that FFI functions do not have a .prototype and throw a TypeError if construction is attempted.

From the review thread

The AIX failure occurred because AIX uses neither of the JavaScript wrapper paths updated in the original commit.

AIX/PowerPC is excluded from the Fast API path because there is no PPC64 big-endian trampoline emitter. The shared-buffer path is also disabled on big-endian platforms. Consequently, AIX falls back to the generic native function created with v8::Function::New().

Function::New() defaults to ConstructorBehavior::kAllow, so the fallback function had its own prototype and remained constructible. Since it had no Fast API or shared-buffer metadata, it was returned without a JavaScript wrapper.

I amended the commit to create the generic fallback and shared-buffer slow-path functions with ConstructorBehavior::kThrow. The concise-method changes still ensure that JavaScript wrappers are non-constructible, while the C++ change covers native fallback paths such as AIX.

No other notable review discussion on this PR.

Takeaways

  • Always consider all code paths—including platform-specific and fallback paths—when enforcing API invariants.
  • In JavaScript, concise method syntax produces non-constructible functions, unlike function expressions. Use this when you want to prevent construction.
  • When exposing native functions to JavaScript, use explicit constructor behavior flags (e.g., v8::ConstructorBehavior::kThrow) to prevent unintended usage.