From 8e82e7a0dd7376e425c7913615567be8e8ae8669 Mon Sep 17 00:00:00 2001 From: Victor Fuentes Date: Mon, 17 Aug 2026 11:46:58 -0700 Subject: [PATCH 1/6] Support PTY process credentials Assisted-by: Zerg:openai/gpt-5.6-sol --- index.d.ts | 7 ++++ src/lib.rs | 109 +++++++++++++++++++++++++++++++++++++++++++++++++++++ wrapper.ts | 12 +++++- 3 files changed, 127 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index a52c3bde..862e4ad4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -25,6 +25,11 @@ export interface SandboxRule { export interface SandboxOptions { rules: Array; } +export interface ProcessCredentials { + uid: number; + gid: number; + supplementaryGids: Array; +} /** The options that can be passed to the constructor of Pty. */ export interface PtyOptions { command: string; @@ -37,6 +42,7 @@ export interface PtyOptions { apparmorProfile?: string; interactive?: boolean; sandbox?: SandboxOptions; + credentials?: ProcessCredentials; onExit: (err: null | Error, exitCode: number) => void; } /** A size struct to pass to resize. */ @@ -59,6 +65,7 @@ export declare function setCloseOnExec(fd: number, closeOnExec: boolean): void; *_CLOEXEC` under the covers. */ export declare function getCloseOnExec(fd: number): boolean; +export declare function clearAmbientCapabilities(): void; export declare class Pty { /** The pid of the forked process. */ pid: number; diff --git a/src/lib.rs b/src/lib.rs index c9584aaa..7a810a79 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -59,6 +59,13 @@ pub struct SandboxOptions { pub rules: Vec, } +#[napi(object)] +pub struct ProcessCredentials { + pub uid: u32, + pub gid: u32, + pub supplementary_gids: Vec, +} + /// The options that can be passed to the constructor of Pty. #[napi(object)] struct PtyOptions { @@ -72,6 +79,7 @@ struct PtyOptions { pub apparmor_profile: Option, pub interactive: Option, pub sandbox: Option, + pub credentials: Option, #[napi(ts_type = "(err: null | Error, exitCode: number) => void")] pub on_exit: JsFunction, } @@ -136,6 +144,14 @@ impl Pty { )); } + #[cfg(not(target_os = "linux"))] + if opts.credentials.is_some() { + return Err(napi::Error::new( + napi::Status::GenericFailure, + "credentials are only supported on Linux", + )); + } + #[cfg(target_os = "linux")] if opts.new_cgroup_namespace.unwrap_or(false) && opts.cgroup_path.is_none() { return Err(napi::Error::new( @@ -297,6 +313,11 @@ impl Pty { libc::signal(libc::SIGTERM, libc::SIG_DFL); libc::signal(libc::SIGALRM, libc::SIG_DFL); + #[cfg(target_os = "linux")] + if let Some(credentials) = &opts.credentials { + apply_process_credentials(credentials)?; + } + Ok(()) }); } @@ -389,6 +410,94 @@ impl Pty { } } +#[cfg(target_os = "linux")] +fn apply_process_credentials(credentials: &ProcessCredentials) -> Result<(), Error> { + const SECBIT_NOROOT: libc::c_ulong = 1 << 0; + const SECBIT_NOROOT_LOCKED: libc::c_ulong = 1 << 1; + const SECBIT_NO_SETUID_FIXUP: libc::c_ulong = 1 << 2; + const SECBIT_NO_SETUID_FIXUP_LOCKED: libc::c_ulong = 1 << 3; + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; + + #[repr(C)] + struct CapabilityHeader { + version: u32, + pid: i32, + } + + #[repr(C)] + struct CapabilityData { + effective: u32, + permitted: u32, + inheritable: u32, + } + + let secure_bits = + SECBIT_NOROOT | SECBIT_NOROOT_LOCKED | SECBIT_NO_SETUID_FIXUP | SECBIT_NO_SETUID_FIXUP_LOCKED; + if unsafe { libc::prctl(libc::PR_SET_SECUREBITS, secure_bits) } != 0 { + return Err(Error::last_os_error()); + } + clear_ambient_capabilities_inner()?; + + let supplementary_gids: Vec = credentials.supplementary_gids.clone(); + if unsafe { libc::setgroups(supplementary_gids.len(), supplementary_gids.as_ptr()) } != 0 { + return Err(Error::last_os_error()); + } + if unsafe { libc::setresgid(credentials.gid, credentials.gid, credentials.gid) } != 0 { + return Err(Error::last_os_error()); + } + if unsafe { libc::setresuid(credentials.uid, credentials.uid, credentials.uid) } != 0 { + return Err(Error::last_os_error()); + } + + let header = CapabilityHeader { + version: LINUX_CAPABILITY_VERSION_3, + pid: 0, + }; + let data = [ + CapabilityData { + effective: 0, + permitted: 0, + inheritable: 0, + }, + CapabilityData { + effective: 0, + permitted: 0, + inheritable: 0, + }, + ]; + if unsafe { libc::syscall(libc::SYS_capset, &header, data.as_ptr()) } != 0 { + return Err(Error::last_os_error()); + } + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(Error::last_os_error()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn clear_ambient_capabilities_inner() -> Result<(), Error> { + if unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_CLEAR_ALL, + 0, + 0, + 0, + ) + } != 0 + { + return Err(Error::last_os_error()); + } + Ok(()) +} + +#[napi] +pub fn clear_ambient_capabilities() -> Result<(), napi::Error> { + #[cfg(target_os = "linux")] + clear_ambient_capabilities_inner().map_err(|err| napi::Error::from_reason(err.to_string()))?; + Ok(()) +} + /// Resize the terminal. #[napi] #[allow(dead_code)] diff --git a/wrapper.ts b/wrapper.ts index 8598e125..fadf8011 100644 --- a/wrapper.ts +++ b/wrapper.ts @@ -8,16 +8,24 @@ import { ptyResize, MAX_U16_VALUE, MIN_U16_VALUE, + clearAmbientCapabilities as rawClearAmbientCapabilities, } from './index.js'; import { type PtyOptions, Operation, type SandboxRule, type SandboxOptions, + type ProcessCredentials, } from './index.js'; import { EOF_EVENT, SyntheticEOFDetector } from './syntheticEof.js'; -export { Operation, type SandboxRule, type SandboxOptions, type PtyOptions }; +export { + Operation, + type SandboxRule, + type SandboxOptions, + type ProcessCredentials, + type PtyOptions, +}; type ExitResult = { error: NodeJS.ErrnoException | null; @@ -232,3 +240,5 @@ export const setCloseOnExec = rawSetCloseOnExec; * FD_CLOEXEC` under the covers. */ export const getCloseOnExec = rawGetCloseOnExec; + +export const clearAmbientCapabilities = rawClearAmbientCapabilities; From fb06239de849f035c423be101a4360b6e6e4b3a1 Mon Sep 17 00:00:00 2001 From: Victor Fuentes Date: Mon, 17 Aug 2026 11:47:58 -0700 Subject: [PATCH 2/6] Allow pid2 to drop unused capabilities Assisted-by: Zerg:openai/gpt-5.6-sol --- index.d.ts | 1 + src/lib.rs | 52 ++++++++++++++++++++++++++++++++++------------------ wrapper.ts | 2 ++ 3 files changed, 37 insertions(+), 18 deletions(-) diff --git a/index.d.ts b/index.d.ts index 862e4ad4..53723fa6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -66,6 +66,7 @@ export declare function setCloseOnExec(fd: number, closeOnExec: boolean): void; */ export declare function getCloseOnExec(fd: number): boolean; export declare function clearAmbientCapabilities(): void; +export declare function clearProcessCapabilities(): void; export declare class Pty { /** The pid of the forked process. */ pid: number; diff --git a/src/lib.rs b/src/lib.rs index 7a810a79..1d6a72f4 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -416,21 +416,6 @@ fn apply_process_credentials(credentials: &ProcessCredentials) -> Result<(), Err const SECBIT_NOROOT_LOCKED: libc::c_ulong = 1 << 1; const SECBIT_NO_SETUID_FIXUP: libc::c_ulong = 1 << 2; const SECBIT_NO_SETUID_FIXUP_LOCKED: libc::c_ulong = 1 << 3; - const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; - - #[repr(C)] - struct CapabilityHeader { - version: u32, - pid: i32, - } - - #[repr(C)] - struct CapabilityData { - effective: u32, - permitted: u32, - inheritable: u32, - } - let secure_bits = SECBIT_NOROOT | SECBIT_NOROOT_LOCKED | SECBIT_NO_SETUID_FIXUP | SECBIT_NO_SETUID_FIXUP_LOCKED; if unsafe { libc::prctl(libc::PR_SET_SECUREBITS, secure_bits) } != 0 { @@ -449,6 +434,30 @@ fn apply_process_credentials(credentials: &ProcessCredentials) -> Result<(), Err return Err(Error::last_os_error()); } + clear_process_capabilities_inner()?; + if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { + return Err(Error::last_os_error()); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn clear_process_capabilities_inner() -> Result<(), Error> { + const LINUX_CAPABILITY_VERSION_3: u32 = 0x2008_0522; + + #[repr(C)] + struct CapabilityHeader { + version: u32, + pid: i32, + } + + #[repr(C)] + struct CapabilityData { + effective: u32, + permitted: u32, + inheritable: u32, + } + let header = CapabilityHeader { version: LINUX_CAPABILITY_VERSION_3, pid: 0, @@ -468,9 +477,6 @@ fn apply_process_credentials(credentials: &ProcessCredentials) -> Result<(), Err if unsafe { libc::syscall(libc::SYS_capset, &header, data.as_ptr()) } != 0 { return Err(Error::last_os_error()); } - if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { - return Err(Error::last_os_error()); - } Ok(()) } @@ -498,6 +504,16 @@ pub fn clear_ambient_capabilities() -> Result<(), napi::Error> { Ok(()) } +#[napi] +pub fn clear_process_capabilities() -> Result<(), napi::Error> { + #[cfg(target_os = "linux")] + { + clear_ambient_capabilities_inner().map_err(|err| napi::Error::from_reason(err.to_string()))?; + clear_process_capabilities_inner().map_err(|err| napi::Error::from_reason(err.to_string()))?; + } + Ok(()) +} + /// Resize the terminal. #[napi] #[allow(dead_code)] diff --git a/wrapper.ts b/wrapper.ts index fadf8011..e54864f7 100644 --- a/wrapper.ts +++ b/wrapper.ts @@ -9,6 +9,7 @@ import { MAX_U16_VALUE, MIN_U16_VALUE, clearAmbientCapabilities as rawClearAmbientCapabilities, + clearProcessCapabilities as rawClearProcessCapabilities, } from './index.js'; import { type PtyOptions, @@ -242,3 +243,4 @@ export const setCloseOnExec = rawSetCloseOnExec; export const getCloseOnExec = rawGetCloseOnExec; export const clearAmbientCapabilities = rawClearAmbientCapabilities; +export const clearProcessCapabilities = rawClearProcessCapabilities; From fbfcd30decabc24b0c2ac267c33c6d74841e5183 Mon Sep 17 00:00:00 2001 From: Victor Fuentes Date: Mon, 17 Aug 2026 14:15:04 -0700 Subject: [PATCH 3/6] Validate process credential IDs Assisted-by: Zerg:openai/gpt-5.6-sol --- src/lib.rs | 85 +++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 78 insertions(+), 7 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 1d6a72f4..3a9be246 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -61,9 +61,9 @@ pub struct SandboxOptions { #[napi(object)] pub struct ProcessCredentials { - pub uid: u32, - pub gid: u32, - pub supplementary_gids: Vec, + pub uid: f64, + pub gid: f64, + pub supplementary_gids: Vec, } /// The options that can be passed to the constructor of Pty. @@ -168,6 +168,13 @@ impl Pty { )); } + #[cfg(target_os = "linux")] + let credentials = opts + .credentials + .as_ref() + .map(ValidatedProcessCredentials::try_from) + .transpose()?; + let size = opts.size.unwrap_or(Size { cols: 80, rows: 24 }); let window_size = Winsize { ws_col: size.cols, @@ -314,7 +321,7 @@ impl Pty { libc::signal(libc::SIGALRM, libc::SIG_DFL); #[cfg(target_os = "linux")] - if let Some(credentials) = &opts.credentials { + if let Some(credentials) = &credentials { apply_process_credentials(credentials)?; } @@ -411,7 +418,66 @@ impl Pty { } #[cfg(target_os = "linux")] -fn apply_process_credentials(credentials: &ProcessCredentials) -> Result<(), Error> { +struct ValidatedProcessCredentials { + uid: libc::uid_t, + gid: libc::gid_t, + supplementary_gids: Vec, +} + +#[cfg(target_os = "linux")] +impl TryFrom<&ProcessCredentials> for ValidatedProcessCredentials { + type Error = napi::Error; + + fn try_from(credentials: &ProcessCredentials) -> Result { + Ok(Self { + uid: validate_process_id(credentials.uid, "uid")?, + gid: validate_process_id(credentials.gid, "gid")?, + supplementary_gids: credentials + .supplementary_gids + .iter() + .map(|id| validate_process_id(*id, "supplementaryGids")) + .collect::>()?, + }) + } +} + +#[cfg(target_os = "linux")] +fn validate_process_id(value: f64, field: &str) -> Result { + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value >= u32::MAX as f64 { + return Err(napi::Error::new( + napi::Status::InvalidArg, + format!( + "credentials.{field} must be an integer between 0 and {}", + u32::MAX - 1 + ), + )); + } + Ok(value as u32) +} + +#[cfg(all(test, target_os = "linux"))] +mod process_credentials_tests { + use super::validate_process_id; + + #[test] + fn accepts_linux_id_range() { + assert_eq!(validate_process_id(0.0, "uid").unwrap(), 0); + assert_eq!( + validate_process_id((u32::MAX - 1) as f64, "uid").unwrap(), + u32::MAX - 1 + ); + } + + #[test] + fn rejects_lossy_or_sentinel_ids() { + for value in [-1.0, 1.5, f64::INFINITY, f64::NAN, u32::MAX as f64] { + assert!(validate_process_id(value, "uid").is_err()); + } + } +} + +#[cfg(target_os = "linux")] +fn apply_process_credentials(credentials: &ValidatedProcessCredentials) -> Result<(), Error> { const SECBIT_NOROOT: libc::c_ulong = 1 << 0; const SECBIT_NOROOT_LOCKED: libc::c_ulong = 1 << 1; const SECBIT_NO_SETUID_FIXUP: libc::c_ulong = 1 << 2; @@ -423,8 +489,13 @@ fn apply_process_credentials(credentials: &ProcessCredentials) -> Result<(), Err } clear_ambient_capabilities_inner()?; - let supplementary_gids: Vec = credentials.supplementary_gids.clone(); - if unsafe { libc::setgroups(supplementary_gids.len(), supplementary_gids.as_ptr()) } != 0 { + if unsafe { + libc::setgroups( + credentials.supplementary_gids.len(), + credentials.supplementary_gids.as_ptr(), + ) + } != 0 + { return Err(Error::last_os_error()); } if unsafe { libc::setresgid(credentials.gid, credentials.gid, credentials.gid) } != 0 { From ccd57754e9f4de06615de15004a6b2e050aff362 Mon Sep 17 00:00:00 2001 From: Victor Fuentes Date: Mon, 17 Aug 2026 15:02:58 -0700 Subject: [PATCH 4/6] Release ruspty 3.9.0 Assisted-by: Zerg:openai/gpt-5.6-sol --- npm/darwin-arm64/package.json | 4 ++-- npm/darwin-x64/package.json | 4 ++-- npm/linux-x64-gnu/package.json | 4 ++-- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index 21847015..c13395c0 100644 --- a/npm/darwin-arm64/package.json +++ b/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty-darwin-arm64", - "version": "3.7.0", + "version": "3.9.0", "os": [ "darwin" ], @@ -19,4 +19,4 @@ "type": "git", "url": "git+https://github.com/replit/ruspty.git" } -} \ No newline at end of file +} diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json index 2d0bf778..c358ce92 100644 --- a/npm/darwin-x64/package.json +++ b/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty-darwin-x64", - "version": "3.7.0", + "version": "3.9.0", "os": [ "darwin" ], @@ -19,4 +19,4 @@ "type": "git", "url": "git+https://github.com/replit/ruspty.git" } -} \ No newline at end of file +} diff --git a/npm/linux-x64-gnu/package.json b/npm/linux-x64-gnu/package.json index 31f59eca..57627f35 100644 --- a/npm/linux-x64-gnu/package.json +++ b/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty-linux-x64-gnu", - "version": "3.7.0", + "version": "3.9.0", "os": [ "linux" ], @@ -22,4 +22,4 @@ "type": "git", "url": "git+https://github.com/replit/ruspty.git" } -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json index c1c9a5b4..b8510515 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@replit/ruspty", - "version": "3.7.0", + "version": "3.9.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@replit/ruspty", - "version": "3.7.0", + "version": "3.9.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2.18.4", diff --git a/package.json b/package.json index 2f61cd65..de30f8a7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty", - "version": "3.7.0", + "version": "3.9.0", "main": "dist/wrapper.js", "types": "dist/wrapper.d.ts", "author": "Szymon Kaliski ", From df84cfd1179d108321eccbd691b82bc21ea20523 Mon Sep 17 00:00:00 2001 From: Victor Fuentes Date: Mon, 17 Aug 2026 16:01:51 -0700 Subject: [PATCH 5/6] Release ruspty 3.8.0 Assisted-by: Zerg:openai/gpt-5.6-sol --- npm/darwin-arm64/package.json | 2 +- npm/darwin-x64/package.json | 2 +- npm/linux-x64-gnu/package.json | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index c13395c0..6da365f6 100644 --- a/npm/darwin-arm64/package.json +++ b/npm/darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty-darwin-arm64", - "version": "3.9.0", + "version": "3.8.0", "os": [ "darwin" ], diff --git a/npm/darwin-x64/package.json b/npm/darwin-x64/package.json index c358ce92..f1c31c6c 100644 --- a/npm/darwin-x64/package.json +++ b/npm/darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty-darwin-x64", - "version": "3.9.0", + "version": "3.8.0", "os": [ "darwin" ], diff --git a/npm/linux-x64-gnu/package.json b/npm/linux-x64-gnu/package.json index 57627f35..a591e6b7 100644 --- a/npm/linux-x64-gnu/package.json +++ b/npm/linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty-linux-x64-gnu", - "version": "3.9.0", + "version": "3.8.0", "os": [ "linux" ], diff --git a/package-lock.json b/package-lock.json index b8510515..3d879cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@replit/ruspty", - "version": "3.9.0", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@replit/ruspty", - "version": "3.9.0", + "version": "3.8.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2.18.4", diff --git a/package.json b/package.json index de30f8a7..08e7bdae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty", - "version": "3.9.0", + "version": "3.8.0", "main": "dist/wrapper.js", "types": "dist/wrapper.d.ts", "author": "Szymon Kaliski ", From ac1c606c9762a584d74a5088730e2463ca29cece Mon Sep 17 00:00:00 2001 From: Victor Fuentes Date: Mon, 17 Aug 2026 21:08:28 -0700 Subject: [PATCH 6/6] Preserve sandbox privilege semantics Assisted-by: Zerg:openai/gpt-5.6-sol --- index.d.ts | 1 + index.js | 6 +++ src/lib.rs | 109 ++++++++++++++++++++++++++++++++++++++------ tests/index.test.ts | 31 +++++++++++++ wrapper.ts | 2 + 5 files changed, 136 insertions(+), 13 deletions(-) diff --git a/index.d.ts b/index.d.ts index 53723fa6..88a88167 100644 --- a/index.d.ts +++ b/index.d.ts @@ -66,6 +66,7 @@ export declare function setCloseOnExec(fd: number, closeOnExec: boolean): void; */ export declare function getCloseOnExec(fd: number): boolean; export declare function clearAmbientCapabilities(): void; +export declare function raiseAmbientCapabilities(capabilities: Array): void; export declare function clearProcessCapabilities(): void; export declare class Pty { /** The pid of the forked process. */ diff --git a/index.js b/index.js index 64824e59..1b77ddba 100644 --- a/index.js +++ b/index.js @@ -330,6 +330,9 @@ const { ptyResize, setCloseOnExec, getCloseOnExec, + clearAmbientCapabilities, + raiseAmbientCapabilities, + clearProcessCapabilities, } = nativeBinding; module.exports.Pty = Pty; @@ -340,3 +343,6 @@ module.exports.getSyntheticEofSequence = getSyntheticEofSequence; module.exports.ptyResize = ptyResize; module.exports.setCloseOnExec = setCloseOnExec; module.exports.getCloseOnExec = getCloseOnExec; +module.exports.clearAmbientCapabilities = clearAmbientCapabilities; +module.exports.raiseAmbientCapabilities = raiseAmbientCapabilities; +module.exports.clearProcessCapabilities = clearProcessCapabilities; diff --git a/src/lib.rs b/src/lib.rs index 3a9be246..5a5f133d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -455,9 +455,28 @@ fn validate_process_id(value: f64, field: &str) -> Result { Ok(value as u32) } +#[cfg(target_os = "linux")] +fn validate_capability(value: f64) -> Result { + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > 63.0 { + return Err(napi::Error::new( + napi::Status::InvalidArg, + "capabilities must contain integers between 0 and 63", + )); + } + + let capability = value as u32; + if unsafe { libc::prctl(libc::PR_CAPBSET_READ, capability as libc::c_ulong, 0, 0, 0) } < 0 { + return Err(napi::Error::new( + napi::Status::InvalidArg, + format!("capability {capability} is not supported by this kernel"), + )); + } + Ok(capability) +} + #[cfg(all(test, target_os = "linux"))] mod process_credentials_tests { - use super::validate_process_id; + use super::{validate_capability, validate_process_id}; #[test] fn accepts_linux_id_range() { @@ -474,19 +493,17 @@ mod process_credentials_tests { assert!(validate_process_id(value, "uid").is_err()); } } + + #[test] + fn rejects_invalid_capabilities() { + for value in [-1.0, 1.5, f64::INFINITY, f64::NAN, 64.0] { + assert!(validate_capability(value).is_err()); + } + } } #[cfg(target_os = "linux")] fn apply_process_credentials(credentials: &ValidatedProcessCredentials) -> Result<(), Error> { - const SECBIT_NOROOT: libc::c_ulong = 1 << 0; - const SECBIT_NOROOT_LOCKED: libc::c_ulong = 1 << 1; - const SECBIT_NO_SETUID_FIXUP: libc::c_ulong = 1 << 2; - const SECBIT_NO_SETUID_FIXUP_LOCKED: libc::c_ulong = 1 << 3; - let secure_bits = - SECBIT_NOROOT | SECBIT_NOROOT_LOCKED | SECBIT_NO_SETUID_FIXUP | SECBIT_NO_SETUID_FIXUP_LOCKED; - if unsafe { libc::prctl(libc::PR_SET_SECUREBITS, secure_bits) } != 0 { - return Err(Error::last_os_error()); - } clear_ambient_capabilities_inner()?; if unsafe { @@ -506,9 +523,6 @@ fn apply_process_credentials(credentials: &ValidatedProcessCredentials) -> Resul } clear_process_capabilities_inner()?; - if unsafe { libc::prctl(libc::PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) } != 0 { - return Err(Error::last_os_error()); - } Ok(()) } @@ -551,6 +565,61 @@ fn clear_process_capabilities_inner() -> Result<(), Error> { Ok(()) } +#[cfg(target_os = "linux")] +fn raise_ambient_capabilities_inner(capabilities: &[u32]) -> Result<(), Error> { + let mut raised = Vec::with_capacity(capabilities.len()); + for capability in capabilities { + let is_set = unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_IS_SET, + *capability as libc::c_ulong, + 0, + 0, + ) + }; + if is_set < 0 { + let error = Error::last_os_error(); + lower_ambient_capabilities(&raised); + return Err(error); + } + if is_set == 1 { + continue; + } + if unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_RAISE, + *capability as libc::c_ulong, + 0, + 0, + ) + } != 0 + { + let error = Error::last_os_error(); + lower_ambient_capabilities(&raised); + return Err(error); + } + raised.push(*capability); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn lower_ambient_capabilities(capabilities: &[u32]) { + for capability in capabilities { + unsafe { + libc::prctl( + libc::PR_CAP_AMBIENT, + libc::PR_CAP_AMBIENT_LOWER, + *capability as libc::c_ulong, + 0, + 0, + ) + }; + } +} + #[cfg(target_os = "linux")] fn clear_ambient_capabilities_inner() -> Result<(), Error> { if unsafe { @@ -575,6 +644,20 @@ pub fn clear_ambient_capabilities() -> Result<(), napi::Error> { Ok(()) } +#[napi] +pub fn raise_ambient_capabilities(capabilities: Vec) -> Result<(), napi::Error> { + #[cfg(target_os = "linux")] + { + let capabilities = capabilities + .into_iter() + .map(validate_capability) + .collect::, _>>()?; + raise_ambient_capabilities_inner(&capabilities) + .map_err(|err| napi::Error::from_reason(err.to_string()))?; + } + Ok(()) +} + #[napi] pub fn clear_process_capabilities() -> Result<(), napi::Error> { #[cfg(target_os = "linux")] diff --git a/tests/index.test.ts b/tests/index.test.ts index 7d55bb10..b4727990 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -931,3 +931,34 @@ describe('setCloseOnExec', () => { setCloseOnExec(0, originalFlag); }); }); + +const testAsLinuxRoot = + process.platform === 'linux' && process.getuid?.() === 0 ? test : test.skip; + +describe('process credentials', () => { + testAsLinuxRoot('preserves normal guest-root privileges', async () => { + let output = ''; + const onExit = vi.fn(); + const pty = new Pty({ + command: 'sh', + args: [ + '-c', + 'grep -E "^(CapEff|CapBnd|NoNewPrivs):" /proc/self/status', + ], + credentials: { uid: 0, gid: 0, supplementaryGids: [] }, + onExit, + }); + pty.read.on('data', (data) => { + output += data.toString(); + }); + + await vi.waitFor(() => expect(onExit).toHaveBeenCalledTimes(1)); + expect(onExit).toHaveBeenCalledWith(null, 0); + expect(output).toContain('NoNewPrivs:\t0'); + const effective = output.match(/CapEff:\s*([0-9a-f]+)/)?.[1]; + const bounding = output.match(/CapBnd:\s*([0-9a-f]+)/)?.[1]; + expect(effective).toBeTruthy(); + expect(effective).not.toBe('0000000000000000'); + expect(effective).toBe(bounding); + }); +}); diff --git a/wrapper.ts b/wrapper.ts index e54864f7..b1417b5b 100644 --- a/wrapper.ts +++ b/wrapper.ts @@ -9,6 +9,7 @@ import { MAX_U16_VALUE, MIN_U16_VALUE, clearAmbientCapabilities as rawClearAmbientCapabilities, + raiseAmbientCapabilities as rawRaiseAmbientCapabilities, clearProcessCapabilities as rawClearProcessCapabilities, } from './index.js'; import { @@ -243,4 +244,5 @@ export const setCloseOnExec = rawSetCloseOnExec; export const getCloseOnExec = rawGetCloseOnExec; export const clearAmbientCapabilities = rawClearAmbientCapabilities; +export const raiseAmbientCapabilities = rawRaiseAmbientCapabilities; export const clearProcessCapabilities = rawClearProcessCapabilities;