Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

permission: handle case-sensitive file systems #47269

Closed
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
33 changes: 33 additions & 0 deletions doc/api/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,14 @@ added: v16.6.0

Use this flag to disable top-level await in REPL.

### `--no-permission-case-sensitive`

<!-- YAML
added: REPLACEME
-->

Use this flag to force disable case-sensitive in the [Permission Model][].

### `--experimental-shadow-realm`

<!-- YAML
Expand Down Expand Up @@ -1082,6 +1090,29 @@ unless either the `--pending-deprecation` command-line flag, or the
are used to provide a kind of selective "early warning" mechanism that
developers may leverage to detect deprecated API usage.

### `--permission-case-sensitive`

<!-- YAML
added: REPLACEME
-->

> Stability: 1 - Experimental

Determines whether the [Permission Model][] considers file and directory
permissions as case-sensitive.

If the flag is enabled, the file system will differentiate between uppercase and
lowercase letters in file and directory permissions, so `/home/user/file.md` and
`/home/USER/fiLE.MD` would be treated as distinct files.

If the flag is disabled, file and directory permissions would be considered
case-insensitive, so `/home/user/file.md` and `/home/USER/fiLE.MD` would be
considered the same file.

The default value for this flag varies depending on the operating system.
Currently, Linux is the only operating system that has this flag enabled by
default. Further information can be found in [Case-insensitive file systems][].

### `--policy-integrity=sri`

<!-- YAML
Expand Down Expand Up @@ -2121,6 +2152,7 @@ Node.js options that are allowed are:
* `--no-extra-info-on-fatal-exception`
* `--no-force-async-hooks-checks`
* `--no-global-search-paths`
* `--no-permission-case-sensitive`
* `--no-warnings`
* `--node-memory-debug`
* `--openssl-config`
Expand Down Expand Up @@ -2523,6 +2555,7 @@ done
```

[#42511]: https://github.com/nodejs/node/issues/42511
[Case-insensitive file systems]: permissions.md#case-insensitive-file-systems
[Chrome DevTools Protocol]: https://chromedevtools.github.io/devtools-protocol/
[CommonJS]: modules.md
[CommonJS module]: modules.md
Expand Down
21 changes: 21 additions & 0 deletions doc/api/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,9 @@ There are constraints you need to know before using this system:
* The model does not inherit to a worker thread.
* When creating symlinks the target (first argument) should have read and
write access.
* Environment with a custom case-sensitive file system,
ensure to use the `--permission-case-sensitive` flag properly.
See [Case-insensitive file systems][] for further information.
* Permission changes are not retroactively applied to existing resources.
Consider the following snippet:
```js
Expand All @@ -586,12 +589,30 @@ const fd = fs.openSync('./README.md', 'r');
// Error: Access to this API has been restricted
```

#### Case-insensitive file systems

Case-insensitive file systems are commonly used in Windows and macOS
environments, but they can also be used in Linux and other Unix-like operating
systems with certain configuration options or software tools.

The Permission Model determines whether file and directory are case-sensitive
from the operating system. This behavior is manageable by the flag
[`--permission-case-sensitive`][]. However, in some cases, an operating system
may have a custom file system configuration where some paths are case-sensitive
and others are case-insensitive. To ensure consistency, it is recommended to
enforce case-insensitivity using the `--no-permission-case-sensitive` flag.
The Permission Model does not allow for multi-path configuration,
so this flag should be used to apply the same case-sensitivity setting to all
paths.

[Case-insensitive file systems]: #case-insensitive-file-systems
[Security Policy]: https://github.com/nodejs/node/blob/main/SECURITY.md
[`--allow-child-process`]: cli.md#--allow-child-process
[`--allow-fs-read`]: cli.md#--allow-fs-read
[`--allow-fs-write`]: cli.md#--allow-fs-write
[`--allow-worker`]: cli.md#--allow-worker
[`--experimental-permission`]: cli.md#--experimental-permission
[`--permission-case-sensitive`]: cli.md#--permission-case-sensitive
[`permission.deny()`]: process.md#processpermissiondenyscope-reference
[`permission.has()`]: process.md#processpermissionhasscope-reference
[import maps]: https://url.spec.whatwg.org/#relative-url-with-fragment-string
Expand Down
2 changes: 2 additions & 0 deletions src/env.cc
Original file line number Diff line number Diff line change
Expand Up @@ -783,6 +783,8 @@ Environment::Environment(IsolateData* isolate_data,
// spawn/worker nor use addons unless explicitly allowed by the user
if (!options_->allow_fs_read.empty() || !options_->allow_fs_write.empty()) {
options_->allow_native_addons = false;
permission()->SetFSPermissionCaseSensitive(
options_->permission_case_sensitive);
if (!options_->allow_child_process) {
permission()->Deny(permission::PermissionScope::kChildProcess, {});
}
Expand Down
4 changes: 4 additions & 0 deletions src/node_options.cc
Original file line number Diff line number Diff line change
Expand Up @@ -420,6 +420,10 @@ EnvironmentOptionsParser::EnvironmentOptionsParser() {
&EnvironmentOptions::experimental_policy_integrity,
kAllowedInEnvvar);
Implies("--policy-integrity", "[has_policy_integrity_string]");
AddOption("--permission-case-sensitive",
"enforces case-sensitive permission checks in the Permission Model",
&EnvironmentOptions::permission_case_sensitive,
kAllowedInEnvvar);
AddOption("--allow-fs-read",
"allow permissions to read the filesystem",
&EnvironmentOptions::allow_fs_read,
Expand Down
8 changes: 8 additions & 0 deletions src/node_options.h
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,13 @@
#include "openssl/opensslv.h"
#endif

#if defined(__linux__)
// Linux filesystems are case-sensitive by default
#define FILESYSTEM_IS_CASE_SENSITIVE true
#else
#define FILESYSTEM_IS_CASE_SENSITIVE false
#endif

namespace node {

class HostPort {
Expand Down Expand Up @@ -121,6 +128,7 @@ class EnvironmentOptions : public Options {
std::string experimental_policy_integrity;
bool has_policy_integrity_string = false;
bool experimental_permission = false;
bool permission_case_sensitive = FILESYSTEM_IS_CASE_SENSITIVE;
std::string allow_fs_read;
std::string allow_fs_write;
bool allow_child_process = false;
Expand Down
28 changes: 26 additions & 2 deletions src/permission/fs_permission.cc
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
#include <limits.h>
#include <stdlib.h>
#include <algorithm>
#include <cctype>
#include <filesystem>
#include <string>
#include <vector>
Expand Down Expand Up @@ -154,6 +155,20 @@ bool FSPermission::is_granted(PermissionScope perm,
}
}

std::string FSPermission::RadixTree::NormalizePathIfCaseInsensitive(
const std::string& path) {
if (!case_sensitive_) {
std::string transformed_path = path;
std::transform(
transformed_path.begin(),
transformed_path.end(),
transformed_path.begin(),
[](unsigned char c) -> unsigned char { return std::tolower(c); });
RafaelGSS marked this conversation as resolved.
Show resolved Hide resolved
return transformed_path;
}
return path;
}

FSPermission::RadixTree::RadixTree() : root_node_(new Node("")) {}

FSPermission::RadixTree::~RadixTree() {
Expand All @@ -168,7 +183,15 @@ bool FSPermission::RadixTree::Lookup(const std::string_view& s,
}

unsigned int parent_node_prefix_len = current_node->prefix.length();
const std::string path(s);
std::string path(s);
if (!case_sensitive_) {
std::transform(
path.begin(),
path.end(),
path.begin(),
[](unsigned char c) -> unsigned char { return std::tolower(c); });
Copy link
Member

Choose a reason for hiding this comment

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

cc @bnoordhuis, I am not sure if this is related to the concern in #47105 (comment).

}

auto path_len = path.length();

while (true) {
Expand All @@ -190,10 +213,11 @@ bool FSPermission::RadixTree::Lookup(const std::string_view& s,
}
}

void FSPermission::RadixTree::Insert(const std::string& path) {
void FSPermission::RadixTree::Insert(const std::string& res) {
FSPermission::RadixTree::Node* current_node = root_node_;

unsigned int parent_node_prefix_len = current_node->prefix.length();
const std::string path = NormalizePathIfCaseInsensitive(res);
int path_len = path.length();

for (int i = 1; i <= path_len; ++i) {
Expand Down
18 changes: 18 additions & 0 deletions src/permission/fs_permission.h
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,19 @@ class FSPermission final : public PermissionBase {
const std::vector<std::string>& params) override;
bool is_granted(PermissionScope perm, const std::string_view& param) override;

// For now, case_sensitive is a constant. Once set, its value won't change.
// FSPermission is constructed *before* cli evaluation.
// That's why we lazy-initialize it here.
void CaseSensitive(const bool sensitive) {
if (case_sensitive_initialized_) return;

case_sensitive_initialized_ = true;
granted_in_fs_.case_sensitive_ = sensitive;
granted_out_fs_.case_sensitive_ = sensitive;
deny_in_fs_.case_sensitive_ = sensitive;
deny_out_fs_.case_sensitive_ = sensitive;
}

// For debugging purposes, use the gist function to print the whole tree
// https://gist.github.com/RafaelGSS/5b4f09c559a54f53f9b7c8c030744d19
struct RadixTree {
Expand Down Expand Up @@ -126,6 +139,9 @@ class FSPermission final : public PermissionBase {
void Insert(const std::string& s);
bool Lookup(const std::string_view& s) { return Lookup(s, false); }
bool Lookup(const std::string_view& s, bool when_empty_return);
std::string NormalizePathIfCaseInsensitive(const std::string& path);
anonrig marked this conversation as resolved.
Show resolved Hide resolved

bool case_sensitive_;

private:
Node* root_node_;
Expand All @@ -135,6 +151,8 @@ class FSPermission final : public PermissionBase {
void GrantAccess(PermissionScope scope, std::string param);
void RestrictAccess(PermissionScope scope,
const std::vector<std::string>& params);

bool case_sensitive_initialized_ = false;
// /tmp/* --grant
// /tmp/dsadsa/t.js denied in runtime
//
Expand Down
10 changes: 10 additions & 0 deletions src/permission/permission.cc
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,16 @@ void Permission::EnablePermissions() {
}
}

void Permission::SetFSPermissionCaseSensitive(const bool sensitive) {
auto it = nodes_.find(PermissionScope::kFileSystem);
if (it != nodes_.end()) {
auto fs_permission = std::static_pointer_cast<FSPermission>(it->second);
if (fs_permission) {
fs_permission->CaseSensitive(sensitive);
}
}
}

void Permission::Apply(const std::string& allow, PermissionScope scope) {
auto permission = nodes_.find(scope);
if (permission != nodes_.end()) {
Expand Down
1 change: 1 addition & 0 deletions src/permission/permission.h
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class Permission {
// Permission.Deny API
bool Deny(PermissionScope scope, const std::vector<std::string>& params);
void EnablePermissions();
void SetFSPermissionCaseSensitive(const bool sensitive);

private:
COLD_NOINLINE bool is_scope_granted(const PermissionScope permission,
Expand Down
51 changes: 51 additions & 0 deletions test/parallel/test-permission-fs-case-insensitive.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
// Flags: --experimental-permission --allow-fs-read=* --no-permission-case-sensitive
'use strict';

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

const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const fixtures = require('../common/fixtures');

const protectedFile = fixtures.path('permission', 'deny', 'protected-file.md');
const protectedFileCapsLetters = fixtures.path('permission', 'deny', 'PrOtEcTeD-File.MD');

{
assert.ok(process.permission.deny('fs.read', [protectedFile]));
}

{
// Guarantee the initial protection
assert.throws(() => {
fs.readFile(protectedFile, () => {});
}, common.expectsError({
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: path.toNamespacedPath(protectedFile),
}));
}

{
// uppercase/capitalize files
assert.throws(() => {
fs.readFile(protectedFile.toUpperCase(), (err) => {
assert.ifError(err);
});
}, common.expectsError({
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: path.toNamespacedPath(protectedFile.toUpperCase()),
}));

assert.throws(() => {
fs.readFile(protectedFileCapsLetters, (err) => {
assert.ifError(err);
});
}, common.expectsError({
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: path.toNamespacedPath(protectedFileCapsLetters),
}));
}
61 changes: 61 additions & 0 deletions test/parallel/test-permission-fs-case-sensitive-default.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Flags: --experimental-permission --allow-fs-read=*
'use strict';

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

const assert = require('node:assert');
const fs = require('node:fs');
const path = require('node:path');
const fixtures = require('../common/fixtures');

const protectedFile = fixtures.path('permission', 'deny', 'protected-file.md');
const protectedFileCapsLetters = fixtures.path('permission', 'deny', 'PrOtEcTeD-File.MD');

{
assert.ok(process.permission.deny('fs.read', [protectedFile]));
}

{
// Guarantee the initial protection
assert.throws(() => {
fs.readFile(protectedFile, () => {});
}, common.expectsError({
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: path.toNamespacedPath(protectedFile),
}));
}

// case-sensitive = true
if (common.isLinux) {
// doesNotThrow
fs.readFile(protectedFile.toUpperCase(), (err) => {
assert.ifError(err);
});

// doesNotThrow
fs.readFile(protectedFileCapsLetters, (err) => {
assert.ifError(err);
});
} else {
assert.throws(() => {
fs.readFile(protectedFile.toUpperCase(), (err) => {
assert.ifError(err);
});
}, common.expectsError({
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: path.toNamespacedPath(protectedFile.toUpperCase()),
}));

assert.throws(() => {
fs.readFile(protectedFileCapsLetters, (err) => {
assert.ifError(err);
});
}, common.expectsError({
code: 'ERR_ACCESS_DENIED',
permission: 'FileSystemRead',
resource: path.toNamespacedPath(protectedFileCapsLetters),
}));
}
Loading