diff --git a/index.d.ts b/index.d.ts index a52c3bde..88a88167 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,9 @@ 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 function raiseAmbientCapabilities(capabilities: Array): void; +export declare function clearProcessCapabilities(): void; export declare class Pty { /** The pid of the forked process. */ pid: number; 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/npm/darwin-arm64/package.json b/npm/darwin-arm64/package.json index 21847015..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.7.0", + "version": "3.8.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..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.7.0", + "version": "3.8.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..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.7.0", + "version": "3.8.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..3d879cf2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@replit/ruspty", - "version": "3.7.0", + "version": "3.8.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@replit/ruspty", - "version": "3.7.0", + "version": "3.8.0", "license": "MIT", "devDependencies": { "@napi-rs/cli": "^2.18.4", diff --git a/package.json b/package.json index 2f61cd65..08e7bdae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@replit/ruspty", - "version": "3.7.0", + "version": "3.8.0", "main": "dist/wrapper.js", "types": "dist/wrapper.d.ts", "author": "Szymon Kaliski ", diff --git a/src/lib.rs b/src/lib.rs index c9584aaa..5a5f133d 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: f64, + pub gid: f64, + 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( @@ -152,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, @@ -297,6 +320,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) = &credentials { + apply_process_credentials(credentials)?; + } + Ok(()) }); } @@ -389,6 +417,257 @@ impl Pty { } } +#[cfg(target_os = "linux")] +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(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_capability, 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()); + } + } + + #[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> { + clear_ambient_capabilities_inner()?; + + 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 { + return Err(Error::last_os_error()); + } + if unsafe { libc::setresuid(credentials.uid, credentials.uid, credentials.uid) } != 0 { + return Err(Error::last_os_error()); + } + + clear_process_capabilities_inner()?; + 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, + }; + 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()); + } + 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 { + 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(()) +} + +#[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")] + { + 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/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 8598e125..b1417b5b 100644 --- a/wrapper.ts +++ b/wrapper.ts @@ -8,16 +8,26 @@ import { ptyResize, MAX_U16_VALUE, MIN_U16_VALUE, + clearAmbientCapabilities as rawClearAmbientCapabilities, + raiseAmbientCapabilities as rawRaiseAmbientCapabilities, + clearProcessCapabilities as rawClearProcessCapabilities, } 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 +242,7 @@ export const setCloseOnExec = rawSetCloseOnExec; * FD_CLOEXEC` under the covers. */ export const getCloseOnExec = rawGetCloseOnExec; + +export const clearAmbientCapabilities = rawClearAmbientCapabilities; +export const raiseAmbientCapabilities = rawRaiseAmbientCapabilities; +export const clearProcessCapabilities = rawClearProcessCapabilities;