Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
52 changes: 51 additions & 1 deletion src/node_file.cc
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ using v8::Undefined;
using v8::Value;

#ifndef S_ISDIR
#define S_ISDIR(mode) (((mode)&S_IFMT) == S_IFDIR)
#define S_ISDIR(mode) (((mode) & S_IFMT) == S_IFDIR)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

this change is not required

#endif

#ifdef __POSIX__
Expand Down Expand Up @@ -3752,6 +3752,30 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {

if (dir_entry.is_symlink()) {
if (verbatim_symlinks) {
// Permission check for verbatimSymlinks path (incomplete
// CVE-2025-55130 fix)
Comment on lines +3755 to +3756
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
// Permission check for verbatimSymlinks path (incomplete
// CVE-2025-55130 fix)
// Permission check for verbatimSymlinks path

It's not an incomplete fix, it's not a vulnerability, I told you.

if (env->permission()->enabled()) {
auto verb_target = std::filesystem::read_symlink(src, error);
if (error) break;
auto verb_target_abs = std::filesystem::weakly_canonical(
std::filesystem::absolute(src.parent_path() / verb_target));
auto verb_str = verb_target_abs.string();
auto verb_view = std::string_view(verb_str);
Comment on lines +3758 to +3763
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

We shouldn't resolve the path and then perform the check, this goes against permission model behavior, we never call a fs syscall to check if we can in-fact read/write to a file, this is controversial (that's why symlinks aren't supported)

if (!env->permission()->is_granted(
env,
permission::PermissionScope::kFileSystemRead,
verb_view) ||
!env->permission()->is_granted(
env,
permission::PermissionScope::kFileSystemWrite,
verb_view)) {
return THROW_ERR_ACCESS_DENIED(
env,
"Access to symlink target '%s' denied",
verb_str.c_str());
}
}

std::filesystem::copy_symlink(
dir_entry.path(), dest_file_path, error);
if (error) {
Expand Down Expand Up @@ -3818,6 +3842,32 @@ static void CpSyncCopyDir(const FunctionCallbackInfo<Value>& args) {
}
auto symlink_target_absolute = std::filesystem::weakly_canonical(
std::filesystem::absolute(src / symlink_target));

// Permission check for symlink target (incomplete CVE-2025-55130 fix)
// Ensure the symlink target is within allowed permission paths
if (env->permission()->enabled()) {
auto target_str = symlink_target_absolute.string();
auto target_view = std::string_view(target_str);
if (!env->permission()->is_granted(
env,
permission::PermissionScope::kFileSystemRead,
target_view)) {
return THROW_ERR_ACCESS_DENIED(
env,
"Access to symlink target '%s' denied",
target_str.c_str());
}
if (!env->permission()->is_granted(
env,
permission::PermissionScope::kFileSystemWrite,
target_view)) {
return THROW_ERR_ACCESS_DENIED(
env,
"Access to symlink target '%s' denied",
target_str.c_str());
}
}

if (dir_entry.is_directory()) {
std::filesystem::create_directory_symlink(
symlink_target_absolute, dest_file_path, error);
Expand Down
93 changes: 93 additions & 0 deletions test/parallel/test-fs-cp-permission-symlink.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
// Flags: --experimental-permission --allow-fs-read=* --allow-fs-write=*
'use strict';

const common = require('../common');

if (!common.hasCrypto) common.skip('missing crypto');

const assert = require('assert');
const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');

// This test verifies that fs.cpSync checks symlink target permissions
// when copying directories containing symlinks.
// Regression test for incomplete CVE-2025-55130 fix.

const tmpdir = require('../common/tmpdir');
tmpdir.refresh();

const allowedDir = path.join(tmpdir.path, 'allowed');
const deniedDir = path.join(tmpdir.path, 'denied');
const srcDir = path.join(allowedDir, 'src');
const destDir = path.join(allowedDir, 'dest');
const secretFile = path.join(deniedDir, 'secret.txt');

// Setup directories
fs.mkdirSync(srcDir, { recursive: true });
fs.mkdirSync(destDir, { recursive: true });
fs.mkdirSync(deniedDir, { recursive: true });
fs.writeFileSync(secretFile, 'SECRET_DATA');

// Create symlink pointing outside allowed path
fs.symlinkSync(secretFile, path.join(srcDir, 'link'));

// Run with restricted permissions — only allowedDir is permitted
const result = execFileSync(process.execPath, [
'--experimental-permission',
`--allow-fs-read=${allowedDir}`,
`--allow-fs-write=${allowedDir}`,
'--allow-fs-read=/usr',
'--allow-fs-read=/lib',
'-e',
`
const fs = require('node:fs');
try {
fs.cpSync('${srcDir}/', '${destDir}/', { recursive: true });
console.log('FAIL');
} catch(e) {
console.log(e.code);
}
`,
], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();

// cpSync should throw ERR_ACCESS_DENIED because symlink target
// (/tmp/.../denied/secret.txt) is outside allowed paths
assert.strictEqual(
result,
'ERR_ACCESS_DENIED',
`Expected ERR_ACCESS_DENIED but got: ${result}`
);

// Also test verbatimSymlinks path
const destDir2 = path.join(allowedDir, 'dest2');
fs.mkdirSync(destDir2, { recursive: true });

const result2 = execFileSync(process.execPath, [
'--experimental-permission',
`--allow-fs-read=${allowedDir}`,
`--allow-fs-write=${allowedDir}`,
'--allow-fs-read=/usr',
'--allow-fs-read=/lib',
'-e',
`
const fs = require('node:fs');
try {
fs.cpSync('${srcDir}/', '${destDir2}/', {
recursive: true,
verbatimSymlinks: true
});
console.log('FAIL');
} catch(e) {
console.log(e.code);
}
`,
], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();

assert.strictEqual(
result2,
'ERR_ACCESS_DENIED',
`verbatimSymlinks: Expected ERR_ACCESS_DENIED but got: ${result2}`
);

console.log('All permission checks passed.');