mirror of
https://github.com/openai/codex.git
synced 2026-11-13 06:45:07 +00:00
Harden Windows sandbox provisioning file handling (#42342)
## Why Windows sandbox provisioning performs privileged work beneath a requested Codex home. Provisioning paths and output entries must remain bound to the files and directories that were validated, without following reparse points or writing through caller-controlled hard links. ## What changed - Add reusable no-reparse directory opens, path validation, directory guards, and handle-relative atomic file replacement. - Use fresh or retained handles for provisioning logs, sandbox credentials, setup markers, and error reports in service provisioning mode. - Add service-side validation for framed provisioning requests, proxy settings, fixed-drive Codex homes, and pinned sandbox directories. ## Testing Add Windows tests covering junction and unsafe-path rejection, hard-link-safe output replacement, retained output handles, request framing and normalization, and ancestor pinning. GitOrigin-RevId: 2ec4f0d15404c3387df1414bb0c1915cf452ecf2
This commit is contained in:
1 parent
501931b399
commit
add870a4bf
16 files changed
+1210
-243
No files matched your search
@@ -1,10 +1,23 @@
|
||||
//! Disabled provisioning IPC until authenticated request handling is installed.
|
||||
|
||||
// Keep these private until the authenticated transport is connected.
|
||||
#[allow(dead_code)]
|
||||
mod home;
|
||||
#[allow(dead_code)]
|
||||
mod request;
|
||||
|
||||
use anyhow::Result;
|
||||
#[cfg(test)]
|
||||
use home::pin_existing_ancestors;
|
||||
#[cfg(test)]
|
||||
use request::ProvisioningRequest;
|
||||
#[cfg(test)]
|
||||
use request::validate_request;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicBool;
|
||||
|
||||
pub(crate) const PIPE_NAME: &str = r"\\.\pipe\OpenAI.CodexSandbox";
|
||||
const MAX_REQUEST_BYTES: usize = 4096;
|
||||
|
||||
pub(crate) fn run(
|
||||
_shutdown: Arc<AtomicBool>,
|
||||
@@ -14,3 +27,7 @@ pub(crate) fn run(
|
||||
}
|
||||
|
||||
pub(crate) fn wake() {}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "ipc_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,158 @@
|
||||
//! Validates and pins local Codex home and sandbox directories, keeping sandbox
|
||||
//! leaves nonempty so they cannot become junctions during provisioning.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_windows_sandbox::DirectoryOpenDisposition;
|
||||
use codex_windows_sandbox::create_directory_guard;
|
||||
use codex_windows_sandbox::open_directory_no_reparse;
|
||||
use codex_windows_sandbox::to_wide;
|
||||
use codex_windows_sandbox::validate_local_directory_path;
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
use std::os::windows::io::BorrowedHandle;
|
||||
use std::os::windows::io::IntoRawHandle;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use windows_sys::Win32::Foundation as foundation;
|
||||
use windows_sys::Win32::Foundation::HANDLE;
|
||||
use windows_sys::Win32::Storage::FileSystem as filesystem;
|
||||
|
||||
const DRIVE_FIXED: u32 = 3;
|
||||
|
||||
pub(super) struct OwnedHandle(pub(super) HANDLE);
|
||||
|
||||
impl Drop for OwnedHandle {
|
||||
fn drop(&mut self) {
|
||||
if self.0 != 0 && self.0 != foundation::INVALID_HANDLE_VALUE {
|
||||
unsafe { foundation::CloseHandle(self.0) };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn prepare_codex_home(requested: &Path) -> Result<(PathBuf, Vec<OwnedHandle>)> {
|
||||
let mut handles = Vec::new();
|
||||
validate_local_directory_path(requested)?;
|
||||
let requested_root = requested
|
||||
.ancestors()
|
||||
.last()
|
||||
.context("find the root of the requested Codex home")?;
|
||||
if unsafe { filesystem::GetDriveTypeW(to_wide(requested_root.as_os_str()).as_ptr()) }
|
||||
!= DRIVE_FIXED
|
||||
{
|
||||
bail!("Codex home must be located on a fixed local drive");
|
||||
}
|
||||
let parent = requested
|
||||
.parent()
|
||||
.context("Codex home must have an existing parent directory")?;
|
||||
pin_existing_ancestors(parent, &mut handles)?;
|
||||
handles.push(pin_directory(
|
||||
requested,
|
||||
filesystem::FILE_READ_ATTRIBUTES,
|
||||
DirectoryOpenDisposition::OpenOrCreate,
|
||||
)?);
|
||||
let home = requested
|
||||
.canonicalize()
|
||||
.with_context(|| format!("canonicalize Codex home {}", requested.display()))?;
|
||||
validate_local_directory_path(&home)?;
|
||||
let root = home
|
||||
.ancestors()
|
||||
.last()
|
||||
.context("find the root of the requested Codex home")?;
|
||||
if unsafe { filesystem::GetDriveTypeW(to_wide(root.as_os_str()).as_ptr()) } != DRIVE_FIXED {
|
||||
bail!("Codex home must be located on a fixed local drive");
|
||||
}
|
||||
if home != requested {
|
||||
pin_existing_ancestors(&home, &mut handles)?;
|
||||
}
|
||||
for (index, directory) in [
|
||||
home.as_path(),
|
||||
&home.join(".sandbox"),
|
||||
&home.join(".sandbox-secrets"),
|
||||
&home.join(".sandbox-bin"),
|
||||
]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let mut access = filesystem::FILE_READ_ATTRIBUTES
|
||||
| filesystem::FILE_ADD_FILE
|
||||
| filesystem::FILE_ADD_SUBDIRECTORY;
|
||||
if index != 0 {
|
||||
access |= filesystem::WRITE_DAC;
|
||||
}
|
||||
let handle = pin_directory(directory, access, DirectoryOpenDisposition::OpenOrCreate)
|
||||
.with_context(|| {
|
||||
if index == 0 {
|
||||
format!(
|
||||
"requesting user must be permitted to write Codex home {}",
|
||||
directory.display()
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
"requesting user must be permitted to write and modify sandbox directory ACL {}",
|
||||
directory.display()
|
||||
)
|
||||
}
|
||||
})?;
|
||||
if index != 0 {
|
||||
// The handle is owned here and remains live until the guard is installed.
|
||||
let directory_handle = unsafe { BorrowedHandle::borrow_raw(handle.0 as _) };
|
||||
let guard = create_directory_guard(directory_handle)?;
|
||||
// Reject conversion before guard creation; after creation the retained
|
||||
// file prevents this directory from becoming empty and being converted.
|
||||
drop(pin_directory(
|
||||
directory,
|
||||
filesystem::FILE_READ_ATTRIBUTES,
|
||||
DirectoryOpenDisposition::OpenExisting,
|
||||
)?);
|
||||
handles.push(OwnedHandle(guard.into_raw_handle() as HANDLE));
|
||||
}
|
||||
handles.push(handle);
|
||||
if index == 0 {
|
||||
continue;
|
||||
}
|
||||
for entry in std::fs::read_dir(directory)? {
|
||||
let entry = entry?;
|
||||
let metadata = entry.path().symlink_metadata()?;
|
||||
if metadata.file_attributes() & filesystem::FILE_ATTRIBUTE_REPARSE_POINT != 0 {
|
||||
bail!("refusing reparse point {}", entry.path().display());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pinned parent handles prevent directory replacement, but existing helper file
|
||||
// opens remain pathname-based; hardlink/leaf-file races need a follow-up redesign.
|
||||
Ok((home, handles))
|
||||
}
|
||||
|
||||
pub(super) fn pin_existing_ancestors(path: &Path, handles: &mut Vec<OwnedHandle>) -> Result<()> {
|
||||
let mut current = PathBuf::new();
|
||||
for component in path.components() {
|
||||
current.push(component.as_os_str());
|
||||
if !matches!(component, Component::Prefix(_)) && current.is_absolute() {
|
||||
handles.push(pin_directory(
|
||||
¤t,
|
||||
filesystem::FILE_READ_ATTRIBUTES,
|
||||
DirectoryOpenDisposition::OpenExisting,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn pin_directory(
|
||||
path: &Path,
|
||||
access: u32,
|
||||
disposition: DirectoryOpenDisposition,
|
||||
) -> Result<OwnedHandle> {
|
||||
let handle = open_directory_no_reparse(
|
||||
path,
|
||||
// Metadata-only access does not enforce no-delete sharing.
|
||||
access | filesystem::FILE_TRAVERSE,
|
||||
filesystem::FILE_SHARE_READ | filesystem::FILE_SHARE_WRITE,
|
||||
disposition,
|
||||
)
|
||||
.with_context(|| format!("pin {}", path.display()))?;
|
||||
Ok(OwnedHandle(handle.into_raw_handle() as HANDLE))
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! Validates framed provisioning requests and normalizes proxy settings.
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use anyhow::bail;
|
||||
use codex_windows_sandbox::PROVISIONING_PROTOCOL_VERSION;
|
||||
use codex_windows_sandbox::ProvisioningMessage;
|
||||
use codex_windows_sandbox::WindowsSandboxProvisioningSettings;
|
||||
use codex_windows_sandbox::WindowsSandboxProxyListeners;
|
||||
use codex_windows_sandbox::read_provisioning_frame;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::MAX_REQUEST_BYTES;
|
||||
|
||||
#[derive(Debug, Eq, PartialEq)]
|
||||
pub(super) struct ProvisioningRequest {
|
||||
pub(super) codex_home: PathBuf,
|
||||
pub(super) listeners: WindowsSandboxProxyListeners,
|
||||
pub(super) settings: WindowsSandboxProvisioningSettings,
|
||||
}
|
||||
|
||||
pub(super) fn validate_request(request: &[u8]) -> Result<ProvisioningRequest> {
|
||||
if request.len() > MAX_REQUEST_BYTES {
|
||||
bail!("provisioning request exceeds size limit");
|
||||
}
|
||||
let mut reader = request;
|
||||
let frame = read_provisioning_frame(&mut reader)
|
||||
.context("invalid framed provisioning request")?
|
||||
.context("provisioning client sent an empty request")?;
|
||||
if !reader.is_empty() {
|
||||
bail!("provisioning requests must contain exactly one IPC frame");
|
||||
}
|
||||
if frame.version != PROVISIONING_PROTOCOL_VERSION {
|
||||
bail!(
|
||||
"unsupported provisioning request version: {}",
|
||||
frame.version
|
||||
);
|
||||
}
|
||||
let ProvisioningMessage::ProvisionSandboxRequest { payload: request } = frame.message else {
|
||||
bail!("expected a sandbox provisioning request");
|
||||
};
|
||||
if request.codex_home.is_empty() || request.codex_home.contains(['\0', '\r', '\n']) {
|
||||
bail!("Codex home is empty or contains an invalid control character");
|
||||
}
|
||||
let mut settings = request.settings;
|
||||
let mut listeners = request.listeners;
|
||||
for ports in [
|
||||
&mut settings.proxy_ports,
|
||||
&mut listeners.http_ports,
|
||||
&mut listeners.socks_ports,
|
||||
] {
|
||||
if ports.contains(&0) {
|
||||
bail!("provisioning request includes an invalid proxy port");
|
||||
}
|
||||
ports.sort_unstable();
|
||||
ports.dedup();
|
||||
}
|
||||
if listeners
|
||||
.http_ports
|
||||
.iter()
|
||||
.chain(&listeners.socks_ports)
|
||||
.any(|port| !settings.proxy_ports.contains(port))
|
||||
{
|
||||
bail!("provisioning listener is absent from the proxy settings");
|
||||
}
|
||||
Ok(ProvisioningRequest {
|
||||
codex_home: PathBuf::from(request.codex_home),
|
||||
listeners,
|
||||
settings,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
use super::ProvisioningRequest;
|
||||
use super::pin_existing_ancestors;
|
||||
use super::validate_request;
|
||||
use codex_windows_sandbox::FramedProvisioningMessage;
|
||||
use codex_windows_sandbox::PROVISIONING_PROTOCOL_VERSION;
|
||||
use codex_windows_sandbox::ProvisioningMessage;
|
||||
use codex_windows_sandbox::SandboxProvisioningRequest;
|
||||
use codex_windows_sandbox::SandboxProvisioningResponse;
|
||||
use codex_windows_sandbox::WindowsSandboxProvisioningSettings;
|
||||
use codex_windows_sandbox::WindowsSandboxProxyListeners;
|
||||
use codex_windows_sandbox::write_provisioning_frame;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn framed_request(request: SandboxProvisioningRequest) -> Vec<u8> {
|
||||
let mut frame = Vec::new();
|
||||
write_provisioning_frame(
|
||||
&mut frame,
|
||||
&FramedProvisioningMessage {
|
||||
version: PROVISIONING_PROTOCOL_VERSION,
|
||||
message: ProvisioningMessage::ProvisionSandboxRequest { payload: request },
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
frame
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisioning_request_preserves_home_spaces_and_unicode() {
|
||||
let request = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: "D:\\Codex Homes\\Jos\u{00e9}\\.codex".to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
});
|
||||
assert_eq!(
|
||||
validate_request(&request).unwrap(),
|
||||
ProvisioningRequest {
|
||||
codex_home: PathBuf::from("D:\\Codex Homes\\Jos\u{00e9}\\.codex"),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_provisioning_request_carries_normalized_proxy_settings() {
|
||||
for (http_port, socks_port, proxy_ports) in
|
||||
[(8081, 3128, vec![3128, 8081]), (8081, 8081, vec![8081])]
|
||||
{
|
||||
let request = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: "D:\\Codex Homes\\Jos\u{00e9}\\.codex".to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings {
|
||||
proxy_ports: vec![http_port, socks_port, http_port],
|
||||
allow_local_binding: true,
|
||||
},
|
||||
listeners: WindowsSandboxProxyListeners {
|
||||
http_ports: vec![http_port, http_port],
|
||||
socks_ports: vec![socks_port],
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
validate_request(&request).unwrap(),
|
||||
ProvisioningRequest {
|
||||
codex_home: PathBuf::from("D:\\Codex Homes\\Jos\u{00e9}\\.codex"),
|
||||
listeners: WindowsSandboxProxyListeners {
|
||||
http_ports: vec![http_port],
|
||||
socks_ports: vec![socks_port],
|
||||
},
|
||||
settings: WindowsSandboxProvisioningSettings {
|
||||
proxy_ports,
|
||||
allow_local_binding: true,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_provisioning_request_accepts_independent_and_additional_proxy_ports() {
|
||||
for (http_ports, socks_ports, proxy_ports) in [
|
||||
(vec![3128], vec![], vec![3128]),
|
||||
(vec![], vec![1080], vec![1080]),
|
||||
(vec![3128, 8080], vec![1080], vec![1080, 1082, 3128, 8080]),
|
||||
] {
|
||||
let settings = WindowsSandboxProvisioningSettings {
|
||||
proxy_ports,
|
||||
allow_local_binding: false,
|
||||
};
|
||||
let listeners = WindowsSandboxProxyListeners {
|
||||
http_ports,
|
||||
socks_ports,
|
||||
};
|
||||
let request = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: r"C:\Users\alice\.codex".to_string(),
|
||||
settings: settings.clone(),
|
||||
listeners: listeners.clone(),
|
||||
});
|
||||
assert_eq!(
|
||||
validate_request(&request).unwrap(),
|
||||
ProvisioningRequest {
|
||||
codex_home: PathBuf::from(r"C:\Users\alice\.codex"),
|
||||
settings,
|
||||
listeners,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_provisioning_request_accepts_disabled_listeners() {
|
||||
let request = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: r"C:\Users\alice\.codex".to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
});
|
||||
assert_eq!(
|
||||
validate_request(&request).unwrap(),
|
||||
ProvisioningRequest {
|
||||
codex_home: PathBuf::from(r"C:\Users\alice\.codex"),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_provisioning_request_requires_exact_version_fields_and_framing() {
|
||||
let valid = SandboxProvisioningRequest {
|
||||
codex_home: r"C:\Users\alice\.codex".to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
};
|
||||
let mut invalid_version = Vec::new();
|
||||
write_provisioning_frame(
|
||||
&mut invalid_version,
|
||||
&FramedProvisioningMessage {
|
||||
version: PROVISIONING_PROTOCOL_VERSION + 1,
|
||||
message: ProvisioningMessage::ProvisionSandboxRequest {
|
||||
payload: valid.clone(),
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_request(&invalid_version).is_err());
|
||||
|
||||
let mut unexpected_message = Vec::new();
|
||||
write_provisioning_frame(
|
||||
&mut unexpected_message,
|
||||
&FramedProvisioningMessage {
|
||||
version: PROVISIONING_PROTOCOL_VERSION,
|
||||
message: ProvisioningMessage::ProvisionSandboxResponse {
|
||||
payload: SandboxProvisioningResponse::Ok,
|
||||
},
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(validate_request(&unexpected_message).is_err());
|
||||
|
||||
let valid = framed_request(valid);
|
||||
assert!(validate_request(&valid[..valid.len() - 1]).is_err());
|
||||
let mut duplicate = valid.clone();
|
||||
duplicate.extend_from_slice(&valid);
|
||||
assert!(validate_request(&duplicate).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn structured_provisioning_request_rejects_invalid_or_inconsistent_ports() {
|
||||
for (proxy_ports, http_ports, socks_ports) in [
|
||||
(vec![0], vec![], vec![]),
|
||||
(vec![3128], vec![0], vec![]),
|
||||
(vec![3128], vec![3128], vec![0]),
|
||||
(vec![3128], vec![3128], vec![8081]),
|
||||
] {
|
||||
let request = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: r"C:\Users\alice\.codex".to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings {
|
||||
proxy_ports,
|
||||
allow_local_binding: false,
|
||||
},
|
||||
listeners: WindowsSandboxProxyListeners {
|
||||
http_ports,
|
||||
socks_ports,
|
||||
},
|
||||
});
|
||||
assert!(validate_request(&request).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn provisioning_request_rejects_empty_control_characters_and_invalid_utf8() {
|
||||
for home in ["", "C:\\safe\0evil", "C:\\safe\rmore", "C:\\safe\nmore"] {
|
||||
let request = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: home.to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
});
|
||||
assert!(validate_request(&request).is_err());
|
||||
}
|
||||
|
||||
let mut invalid_utf8 = framed_request(SandboxProvisioningRequest {
|
||||
codex_home: r"C:\Users\alice\.codex".to_string(),
|
||||
settings: WindowsSandboxProvisioningSettings::default(),
|
||||
listeners: WindowsSandboxProxyListeners::default(),
|
||||
});
|
||||
invalid_utf8[std::mem::size_of::<u32>()] = 0xff;
|
||||
assert!(validate_request(&invalid_utf8).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pin_existing_ancestors_accepts_drive_and_verbatim_drive_roots() {
|
||||
let executable = std::env::current_exe().unwrap().canonicalize().unwrap();
|
||||
let verbatim_root = executable.ancestors().last().unwrap();
|
||||
let ordinary_root = PathBuf::from(
|
||||
verbatim_root
|
||||
.to_str()
|
||||
.unwrap()
|
||||
.strip_prefix(r"\\?\")
|
||||
.unwrap(),
|
||||
);
|
||||
|
||||
for root in [ordinary_root.as_path(), verbatim_root] {
|
||||
let mut handles = Vec::new();
|
||||
pin_existing_ancestors(root, &mut handles).unwrap();
|
||||
assert_eq!(handles.len(), 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user