返回 CodeWhale
external_credentials.rs
根目录 / crates / tui / src / external_credentials.rs
1 //! Capability-gated I/O for credentials owned by another CLI.
2 //!
3 //! Every external open/read stays behind an opaque grant. Consumption opens
4 //! one absolute regular file through a no-follow traversal, validates that
5 //! same handle, and reads a bounded payload from it. This prevents a consented
6 //! path from being redirected through a leaf or parent symlink/reparse point
7 //! and avoids the old exists-then-read race.
8
9 use std::fs::File;
10 use std::io::{self, Read};
11 use std::path::Path;
12
13 use anyhow::{Context, Result, bail};
14 use codewhale_config::ExternalCredentialReadGrant;
15
16 /// Credential JSON is expected to be tiny. Bound reads so a replaced regular
17 /// file cannot turn read-only consent into unbounded memory consumption.
18 const MAX_EXTERNAL_CREDENTIAL_BYTES: u64 = 1024 * 1024;
19
20 #[cfg(all(test, unix))]
21 thread_local! {
22 static BEFORE_LEAF_OPEN_HOOK: std::cell::RefCell<Option<Box<dyn FnOnce()>>> =
23 std::cell::RefCell::new(None);
24 }
25
26 #[cfg(test)]
27 thread_local! {
28 /// Per-test-thread real sink counters. Keeping the trap thread-local makes
29 /// parallel tests unable to contaminate one another while still counting
30 /// the exact production functions reached by the code under test.
31 static SIDE_EFFECT_TRAP: std::cell::Cell<[usize; 5]> = const {
32 std::cell::Cell::new([0; 5])
33 };
34 }
35
36 #[cfg(test)]
37 fn increment_side_effect(index: usize) {
38 SIDE_EFFECT_TRAP.with(|trap| {
39 let mut counts = trap.get();
40 counts[index] += 1;
41 trap.set(counts);
42 });
43 }
44
45 /// Open and read the exact granted file once. Missing files are reported as
46 /// `Ok(None)`; every other unsafe or malformed filesystem shape fails closed.
47 pub(crate) fn read_to_string(grant: &ExternalCredentialReadGrant) -> Result<Option<String>> {
48 #[cfg(test)]
49 increment_side_effect(0);
50
51 let mut file = match open_secure_regular_file(grant.path(), false) {
52 Ok(file) => file,
53 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
54 Err(error) => {
55 return Err(error).with_context(|| {
56 format!(
57 "securely opening external {} credential file {}",
58 grant.source().as_str(),
59 codewhale_config::quote_os_path(grant.path())
60 )
61 });
62 }
63 };
64
65 #[cfg(test)]
66 increment_side_effect(1);
67
68 let mut bytes = Vec::new();
69 file.by_ref()
70 .take(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
71 .read_to_end(&mut bytes)
72 .with_context(|| {
73 format!(
74 "reading external {} credential file {}",
75 grant.source().as_str(),
76 codewhale_config::quote_os_path(grant.path())
77 )
78 })?;
79 if bytes.len() as u64 > MAX_EXTERNAL_CREDENTIAL_BYTES {
80 bail!(
81 "external {} credential file {} exceeds the {} byte safety limit",
82 grant.source().as_str(),
83 codewhale_config::quote_os_path(grant.path()),
84 MAX_EXTERNAL_CREDENTIAL_BYTES
85 );
86 }
87 let contents = String::from_utf8(bytes).with_context(|| {
88 format!(
89 "external {} credential file {} is not valid UTF-8",
90 grant.source().as_str(),
91 codewhale_config::quote_os_path(grant.path())
92 )
93 })?;
94 Ok(Some(contents))
95 }
96
97 /// Read one Codewhale-owned credential file through the same no-follow,
98 /// bounded I/O boundary used for external grants. On Unix the opened handle
99 /// must belong to the effective user and have no group/other permission bits.
100 /// The caller is responsible for constraining `path` to a validated basename
101 /// below Codewhale's credentials directory before invoking this function.
102 pub(crate) fn read_codewhale_owned_to_string(path: &Path) -> Result<Option<String>> {
103 let mut file = match open_secure_regular_file(path, true) {
104 Ok(file) => file,
105 Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
106 Err(error) => {
107 return Err(error).with_context(|| {
108 format!(
109 "securely opening Codewhale-owned credential file {}",
110 codewhale_config::quote_os_path(path)
111 )
112 });
113 }
114 };
115 let mut bytes = Vec::new();
116 file.by_ref()
117 .take(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
118 .read_to_end(&mut bytes)
119 .with_context(|| {
120 format!(
121 "reading Codewhale-owned credential file {}",
122 codewhale_config::quote_os_path(path)
123 )
124 })?;
125 if bytes.len() as u64 > MAX_EXTERNAL_CREDENTIAL_BYTES {
126 bail!(
127 "Codewhale-owned credential file {} exceeds the {} byte safety limit",
128 codewhale_config::quote_os_path(path),
129 MAX_EXTERNAL_CREDENTIAL_BYTES
130 );
131 }
132 String::from_utf8(bytes).map(Some).with_context(|| {
133 format!(
134 "Codewhale-owned credential file {} is not valid UTF-8",
135 codewhale_config::quote_os_path(path)
136 )
137 })
138 }
139
140 #[cfg(unix)]
141 fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result<File> {
142 use std::ffi::CString;
143 use std::os::fd::FromRawFd;
144 use std::os::unix::ffi::OsStrExt;
145 use std::path::Component;
146
147 if !path.is_absolute() {
148 return Err(io::Error::new(
149 io::ErrorKind::InvalidInput,
150 "external credential path must be absolute",
151 ));
152 }
153
154 let root = CString::new("/").expect("static root contains no NUL");
155 // SAFETY: `root` is a valid C string and flags require no variadic mode.
156 let root_fd = unsafe {
157 libc::open(
158 root.as_ptr(),
159 libc::O_RDONLY | libc::O_DIRECTORY | libc::O_CLOEXEC,
160 )
161 };
162 if root_fd < 0 {
163 return Err(io::Error::last_os_error());
164 }
165 // SAFETY: `root_fd` is newly owned after the successful `open`.
166 let mut current = unsafe { File::from_raw_fd(root_fd) };
167 let mut normals = path
168 .components()
169 .filter_map(|component| match component {
170 Component::Normal(part) => Some(Ok(part)),
171 Component::RootDir => None,
172 Component::Prefix(_) | Component::CurDir | Component::ParentDir => {
173 Some(Err(io::Error::new(
174 io::ErrorKind::InvalidInput,
175 "external credential path must be lexically normalized",
176 )))
177 }
178 })
179 .peekable();
180
181 let mut opened_leaf = false;
182 while let Some(component) = normals.next() {
183 let component = component?;
184 let component = CString::new(component.as_bytes()).map_err(|_| {
185 io::Error::new(
186 io::ErrorKind::InvalidInput,
187 "external credential path contains a NUL byte",
188 )
189 })?;
190 let leaf = normals.peek().is_none();
191 #[cfg(test)]
192 if leaf {
193 BEFORE_LEAF_OPEN_HOOK.with(|hook| {
194 if let Some(hook) = hook.borrow_mut().take() {
195 hook();
196 }
197 });
198 }
199 let flags = if leaf {
200 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_NONBLOCK
201 } else {
202 libc::O_RDONLY | libc::O_CLOEXEC | libc::O_NOFOLLOW | libc::O_DIRECTORY
203 };
204 use std::os::fd::AsRawFd;
205 // SAFETY: the directory fd and component C string are valid for this
206 // call and flags require no variadic mode.
207 let fd = unsafe { libc::openat(current.as_raw_fd(), component.as_ptr(), flags) };
208 if fd < 0 {
209 return Err(io::Error::last_os_error());
210 }
211 // SAFETY: `fd` is newly owned after the successful `openat`.
212 current = unsafe { File::from_raw_fd(fd) };
213 opened_leaf = leaf;
214 }
215
216 if !opened_leaf {
217 return Err(io::Error::new(
218 io::ErrorKind::InvalidInput,
219 "external credential path must name a file",
220 ));
221 }
222 let metadata = current.metadata()?;
223 if !metadata.file_type().is_file() {
224 return Err(io::Error::new(
225 io::ErrorKind::InvalidInput,
226 "external credential path must name a regular file",
227 ));
228 }
229 if require_owner_only {
230 use std::os::unix::fs::MetadataExt as _;
231 if metadata.uid() != unsafe { libc::geteuid() }
232 || metadata.mode() & 0o077 != 0
233 || metadata.nlink() != 1
234 {
235 return Err(io::Error::new(
236 io::ErrorKind::PermissionDenied,
237 "Codewhale-owned credential file must be singly linked, owned by this user, and mode 0600 or stricter",
238 ));
239 }
240 }
241 Ok(current)
242 }
243
244 #[cfg(windows)]
245 fn open_secure_regular_file(path: &Path, require_owner_only: bool) -> io::Result<File> {
246 use std::ffi::OsString;
247 use std::os::windows::ffi::OsStringExt;
248 use std::os::windows::fs::{MetadataExt, OpenOptionsExt};
249 use std::os::windows::io::AsRawHandle;
250 use std::path::Component;
251 use windows_sys::Win32::Storage::FileSystem::{
252 FILE_ATTRIBUTE_REPARSE_POINT, FILE_FLAG_OPEN_REPARSE_POINT, FILE_NAME_OPENED,
253 GetFinalPathNameByHandleW, VOLUME_NAME_DOS,
254 };
255
256 if !path.is_absolute()
257 || path
258 .components()
259 .any(|component| matches!(component, Component::CurDir | Component::ParentDir))
260 {
261 return Err(io::Error::new(
262 io::ErrorKind::InvalidInput,
263 "external credential path must be absolute and lexically normalized",
264 ));
265 }
266
267 // Reject every reparse-point component before the final open. The final
268 // handle is opened as the reparse point itself, checked again, and its
269 // kernel-resolved path is compared below. A second component pass catches
270 // replacement during the open window.
271 reject_windows_reparse_components(path)?;
272 let file = std::fs::OpenOptions::new()
273 .read(true)
274 .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT)
275 .open(path)?;
276 let metadata = file.metadata()?;
277 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0
278 || !metadata.file_type().is_file()
279 {
280 return Err(io::Error::new(
281 io::ErrorKind::InvalidInput,
282 "external credential path must name a non-reparse regular file",
283 ));
284 }
285 reject_windows_reparse_components(path)?;
286
287 let handle = file.as_raw_handle();
288 // Compare the spelling Windows actually opened rather than asking it to
289 // expand the path into its normalized long form. A valid caller path can
290 // contain an 8.3 component such as `RUNNER~1`; normalizing only the handle
291 // side would make that exact path look redirected. FILE_NAME_OPENED keeps
292 // the comparison handle-relative while the pre/post component checks above
293 // continue to reject reparse points and swaps.
294 let flags = FILE_NAME_OPENED | VOLUME_NAME_DOS;
295 // SAFETY: the handle remains owned by `file`; null output asks Windows for
296 // the required UTF-16 buffer length.
297 let needed = unsafe { GetFinalPathNameByHandleW(handle, std::ptr::null_mut(), 0, flags) };
298 if needed == 0 {
299 return Err(io::Error::last_os_error());
300 }
301 let mut buffer = vec![0u16; needed as usize + 1];
302 // SAFETY: `buffer` is writable for its declared length and `handle` is
303 // valid for the duration of the call.
304 let written = unsafe {
305 GetFinalPathNameByHandleW(handle, buffer.as_mut_ptr(), buffer.len() as u32, flags)
306 };
307 if written == 0 || written as usize >= buffer.len() {
308 return Err(io::Error::last_os_error());
309 }
310 let final_path = OsString::from_wide(&buffer[..written as usize]);
311 let actual = normalize_windows_path_for_comparison(Path::new(&final_path))?;
312 let expected = normalize_windows_path_for_comparison(path)?;
313 if actual != expected {
314 return Err(io::Error::new(
315 io::ErrorKind::PermissionDenied,
316 "external credential path was redirected while opening",
317 ));
318 }
319 if require_owner_only {
320 use windows_sys::Win32::Storage::FileSystem::{
321 BY_HANDLE_FILE_INFORMATION, GetFileInformationByHandle,
322 };
323 let mut information = BY_HANDLE_FILE_INFORMATION::default();
324 // SAFETY: the opened credential handle and output pointer remain valid
325 // for the duration of the call.
326 if unsafe { GetFileInformationByHandle(handle, &mut information) } == 0 {
327 return Err(io::Error::last_os_error());
328 }
329 if information.nNumberOfLinks != 1 {
330 return Err(io::Error::new(
331 io::ErrorKind::PermissionDenied,
332 "Codewhale-owned credential file must be singly linked",
333 ));
334 }
335 verify_windows_owner_only_handle(handle)?;
336 }
337 Ok(file)
338 }
339
340 /// Normalize a Windows path without replacement characters. Unpaired UTF-16
341 /// is rejected so two distinct paths can never compare equal after a lossy
342 /// conversion. This is intentionally stricter than filesystem display.
343 #[cfg(windows)]
344 fn normalize_windows_path_for_comparison(path: &Path) -> io::Result<String> {
345 let text = path.to_str().ok_or_else(|| {
346 io::Error::new(
347 io::ErrorKind::PermissionDenied,
348 "credential path contains invalid Unicode and cannot be compared safely",
349 )
350 })?;
351 let without_device_prefix = text.strip_prefix(r"\\?\").unwrap_or(text);
352 let normalized_prefix = without_device_prefix.strip_prefix("UNC\\").map_or_else(
353 || without_device_prefix.to_string(),
354 |rest| format!(r"\\{rest}"),
355 );
356 Ok(normalized_prefix
357 .replace('/', "\\")
358 .trim_end_matches('\\')
359 .to_lowercase())
360 }
361
362 /// Apply a protected DACL granting only the current Windows user full access.
363 /// Directories propagate that owner-only policy to newly staged generations.
364 #[cfg(all(windows, test))]
365 pub(crate) fn secure_codewhale_owned_windows_path(
366 path: &Path,
367 inherit_to_children: bool,
368 ) -> io::Result<()> {
369 use std::os::windows::ffi::OsStrExt as _;
370 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
371 use windows_sys::Win32::Security::Authorization::{
372 EXPLICIT_ACCESS_W, SE_FILE_OBJECT, SET_ACCESS, SetEntriesInAclW, SetNamedSecurityInfoW,
373 TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W,
374 };
375 use windows_sys::Win32::Security::{
376 DACL_SECURITY_INFORMATION, NO_INHERITANCE, OWNER_SECURITY_INFORMATION,
377 PROTECTED_DACL_SECURITY_INFORMATION, SUB_CONTAINERS_AND_OBJECTS_INHERIT,
378 };
379 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
380
381 let user = CurrentWindowsUser::open()?;
382 let entry = EXPLICIT_ACCESS_W {
383 grfAccessPermissions: FILE_ALL_ACCESS,
384 grfAccessMode: SET_ACCESS,
385 grfInheritance: if inherit_to_children {
386 SUB_CONTAINERS_AND_OBJECTS_INHERIT
387 } else {
388 NO_INHERITANCE
389 },
390 Trustee: TRUSTEE_W {
391 pMultipleTrustee: std::ptr::null_mut(),
392 MultipleTrusteeOperation: 0,
393 TrusteeForm: TRUSTEE_IS_SID,
394 TrusteeType: TRUSTEE_IS_USER,
395 ptstrName: user.sid().cast::<u16>(),
396 },
397 };
398 let mut acl = std::ptr::null_mut();
399 // SAFETY: `entry` and the returned ACL stay live through the security-info
400 // update; the ACL is released with LocalFree below.
401 let result = unsafe { SetEntriesInAclW(1, &entry, std::ptr::null(), &mut acl) };
402 if result != ERROR_SUCCESS {
403 return Err(io::Error::from_raw_os_error(result as i32));
404 }
405 let _acl = WindowsLocalAllocation(acl.cast());
406 let wide: Vec<u16> = path.as_os_str().encode_wide().chain([0]).collect();
407 // SAFETY: `wide` is NUL terminated and `acl` remains allocated for the
408 // duration of this call. Owner and DACL are applied together to match the
409 // production current-user-only verifier.
410 let result = unsafe {
411 SetNamedSecurityInfoW(
412 wide.as_ptr(),
413 SE_FILE_OBJECT,
414 OWNER_SECURITY_INFORMATION
415 | DACL_SECURITY_INFORMATION
416 | PROTECTED_DACL_SECURITY_INFORMATION,
417 user.sid(),
418 std::ptr::null_mut(),
419 acl,
420 std::ptr::null(),
421 )
422 };
423 if result != ERROR_SUCCESS {
424 return Err(io::Error::from_raw_os_error(result as i32));
425 }
426 Ok(())
427 }
428
429 #[cfg(windows)]
430 fn verify_windows_owner_only_handle(
431 handle: windows_sys::Win32::Foundation::HANDLE,
432 ) -> io::Result<()> {
433 use windows_sys::Win32::Foundation::ERROR_SUCCESS;
434 use windows_sys::Win32::Security::Authorization::{
435 EXPLICIT_ACCESS_W, GRANT_ACCESS, GetExplicitEntriesFromAclW, GetSecurityInfo,
436 SE_FILE_OBJECT, SET_ACCESS, TRUSTEE_IS_SID,
437 };
438 use windows_sys::Win32::Security::{
439 ACL, DACL_SECURITY_INFORMATION, EqualSid, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR,
440 PSID,
441 };
442 use windows_sys::Win32::Storage::FileSystem::FILE_ALL_ACCESS;
443
444 let user = CurrentWindowsUser::open()?;
445 let mut owner: PSID = std::ptr::null_mut();
446 let mut dacl: *mut ACL = std::ptr::null_mut();
447 let mut descriptor: PSECURITY_DESCRIPTOR = std::ptr::null_mut();
448 // SAFETY: the opened file handle remains valid and all output pointers are
449 // writable. Windows allocates `descriptor`, released below.
450 let result = unsafe {
451 GetSecurityInfo(
452 handle,
453 SE_FILE_OBJECT,
454 OWNER_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION,
455 &mut owner,
456 std::ptr::null_mut(),
457 &mut dacl,
458 std::ptr::null_mut(),
459 &mut descriptor,
460 )
461 };
462 if result != ERROR_SUCCESS {
463 return Err(io::Error::from_raw_os_error(result as i32));
464 }
465 let _descriptor = WindowsLocalAllocation(descriptor.cast());
466 if owner.is_null() || unsafe { EqualSid(owner, user.sid()) } == 0 {
467 return Err(io::Error::new(
468 io::ErrorKind::PermissionDenied,
469 "Codewhale-owned credential file owner is not the current user",
470 ));
471 }
472 if dacl.is_null() {
473 return Err(io::Error::new(
474 io::ErrorKind::PermissionDenied,
475 "Codewhale-owned credential file must have an owner-only DACL",
476 ));
477 }
478 let mut count = 0;
479 let mut entries: *mut EXPLICIT_ACCESS_W = std::ptr::null_mut();
480 // SAFETY: `dacl` is owned by the live security descriptor; Windows
481 // allocates the returned entries, released below.
482 let result = unsafe { GetExplicitEntriesFromAclW(dacl, &mut count, &mut entries) };
483 if result != ERROR_SUCCESS {
484 return Err(io::Error::from_raw_os_error(result as i32));
485 }
486 let _entries = WindowsLocalAllocation(entries.cast());
487 if count != 1 || entries.is_null() {
488 return Err(io::Error::new(
489 io::ErrorKind::PermissionDenied,
490 "Codewhale-owned credential file DACL must grant only one user",
491 ));
492 }
493 // SAFETY: `count == 1` proves the first returned entry is initialized.
494 let entry = unsafe { &*entries };
495 let trustee_sid: PSID = entry.Trustee.ptstrName.cast();
496 let current_user_only = entry.Trustee.TrusteeForm == TRUSTEE_IS_SID
497 && !trustee_sid.is_null()
498 && unsafe { EqualSid(trustee_sid, user.sid()) } != 0
499 && matches!(entry.grfAccessMode, SET_ACCESS | GRANT_ACCESS)
500 && entry.grfAccessPermissions == FILE_ALL_ACCESS;
501 if !current_user_only {
502 return Err(io::Error::new(
503 io::ErrorKind::PermissionDenied,
504 "Codewhale-owned credential file DACL is not current-user-only",
505 ));
506 }
507 Ok(())
508 }
509
510 #[cfg(windows)]
511 struct CurrentWindowsUser {
512 token: windows_sys::Win32::Foundation::HANDLE,
513 token_info: Vec<usize>,
514 }
515
516 #[cfg(windows)]
517 impl CurrentWindowsUser {
518 fn open() -> io::Result<Self> {
519 use windows_sys::Win32::Foundation::{GetLastError, HANDLE};
520 use windows_sys::Win32::Security::{
521 GetTokenInformation, TOKEN_QUERY, TOKEN_USER, TokenUser,
522 };
523 use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken};
524
525 let mut token: HANDLE = std::ptr::null_mut();
526 // SAFETY: the pseudo-process handle is valid and `token` is writable.
527 if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 {
528 return Err(io::Error::last_os_error());
529 }
530 let mut needed = 0;
531 // SAFETY: the null buffer/zero length call obtains the required size.
532 let _ =
533 unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut needed) };
534 if needed == 0 {
535 let error = io::Error::from_raw_os_error(unsafe { GetLastError() } as i32);
536 unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
537 return Err(error);
538 }
539 let words = (needed as usize).div_ceil(std::mem::size_of::<usize>());
540 let mut token_info = vec![0usize; words];
541 // SAFETY: the word buffer is aligned and contains at least `needed`
542 // writable bytes; `token` remains open.
543 if unsafe {
544 GetTokenInformation(
545 token,
546 TokenUser,
547 token_info.as_mut_ptr().cast(),
548 needed,
549 &mut needed,
550 )
551 } == 0
552 {
553 let error = io::Error::last_os_error();
554 unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
555 return Err(error);
556 }
557 let user = unsafe { &*token_info.as_ptr().cast::<TOKEN_USER>() };
558 if user.User.Sid.is_null() {
559 unsafe { windows_sys::Win32::Foundation::CloseHandle(token) };
560 return Err(io::Error::new(
561 io::ErrorKind::InvalidData,
562 "current Windows user token has no SID",
563 ));
564 }
565 Ok(Self { token, token_info })
566 }
567
568 fn sid(&self) -> windows_sys::Win32::Security::PSID {
569 use windows_sys::Win32::Security::TOKEN_USER;
570 // SAFETY: `token_info` is aligned, initialized by GetTokenInformation,
571 // and remains owned by `self` while the returned SID is used.
572 unsafe { (*self.token_info.as_ptr().cast::<TOKEN_USER>()).User.Sid }
573 }
574 }
575
576 #[cfg(windows)]
577 impl Drop for CurrentWindowsUser {
578 fn drop(&mut self) {
579 // SAFETY: `token` is owned by this guard and closed exactly once.
580 unsafe { windows_sys::Win32::Foundation::CloseHandle(self.token) };
581 }
582 }
583
584 #[cfg(windows)]
585 struct WindowsLocalAllocation(*mut core::ffi::c_void);
586
587 #[cfg(windows)]
588 impl Drop for WindowsLocalAllocation {
589 fn drop(&mut self) {
590 if !self.0.is_null() {
591 // SAFETY: Windows returned this allocation to a caller documented
592 // to release it with LocalFree; the guard frees it exactly once.
593 unsafe { windows_sys::Win32::Foundation::LocalFree(self.0) };
594 }
595 }
596 }
597
598 #[cfg(windows)]
599 fn reject_windows_reparse_components(path: &Path) -> io::Result<()> {
600 use std::os::windows::fs::MetadataExt;
601 use windows_sys::Win32::Storage::FileSystem::FILE_ATTRIBUTE_REPARSE_POINT;
602
603 let mut current = std::path::PathBuf::new();
604 for component in path.components() {
605 current.push(component.as_os_str());
606 if matches!(
607 component,
608 std::path::Component::Prefix(_) | std::path::Component::RootDir
609 ) {
610 continue;
611 }
612 let metadata = std::fs::symlink_metadata(&current)?;
613 if metadata.file_attributes() & FILE_ATTRIBUTE_REPARSE_POINT != 0 {
614 return Err(io::Error::new(
615 io::ErrorKind::PermissionDenied,
616 format!(
617 "external credential path contains reparse point {}",
618 codewhale_config::quote_os_path(&current)
619 ),
620 ));
621 }
622 }
623 Ok(())
624 }
625
626 #[cfg(not(any(unix, windows)))]
627 fn open_secure_regular_file(_path: &Path, _require_owner_only: bool) -> io::Result<File> {
628 Err(io::Error::new(
629 io::ErrorKind::Unsupported,
630 "secure external credential reads are unsupported on this platform",
631 ))
632 }
633
634 #[cfg(test)]
635 pub(crate) fn reset_side_effect_trap() {
636 SIDE_EFFECT_TRAP.with(|trap| trap.set([0; 5]));
637 }
638
639 #[cfg(test)]
640 #[must_use]
641 pub(crate) fn side_effect_trap_counts() -> (usize, usize) {
642 SIDE_EFFECT_TRAP.with(|trap| {
643 let counts = trap.get();
644 (counts[0], counts[1])
645 })
646 }
647
648 #[cfg(test)]
649 #[must_use]
650 pub(crate) fn complete_side_effect_trap_counts() -> (usize, usize, usize, usize, usize) {
651 SIDE_EFFECT_TRAP.with(|trap| {
652 let counts = trap.get();
653 (counts[0], counts[1], counts[2], counts[3], counts[4])
654 })
655 }
656
657 #[cfg(test)]
658 pub(crate) fn record_owned_credential_write() {
659 increment_side_effect(2);
660 }
661
662 #[cfg(test)]
663 pub(crate) fn record_oauth_refresh() {
664 increment_side_effect(3);
665 }
666
667 #[cfg(test)]
668 pub(crate) fn record_oauth_network() {
669 increment_side_effect(4);
670 }
671
672 #[cfg(test)]
673 mod tests {
674 use super::*;
675 use codewhale_config::{ExternalCredentialConsentToml, ExternalCredentialSource, ProviderKind};
676
677 fn grant(path: &Path) -> ExternalCredentialReadGrant {
678 ExternalCredentialConsentToml::read_only(
679 ProviderKind::OpenaiCodex,
680 ExternalCredentialSource::CodexCli,
681 path.to_path_buf(),
682 )
683 .read_grant(
684 ProviderKind::OpenaiCodex,
685 ExternalCredentialSource::CodexCli,
686 path,
687 )
688 .expect("test grant")
689 }
690
691 #[test]
692 fn secure_read_accepts_one_bounded_regular_file() {
693 let _env = crate::test_support::lock_test_env();
694 let dir = tempfile::tempdir().expect("tempdir");
695 let path = dir
696 .path()
697 .canonicalize()
698 .expect("canonical temp root")
699 .join("auth.json");
700 std::fs::write(&path, "{\"token\":\"ok\"}").expect("fixture");
701 assert_eq!(
702 read_to_string(&grant(&path))
703 .expect("secure read")
704 .as_deref(),
705 Some("{\"token\":\"ok\"}")
706 );
707 }
708
709 #[cfg(unix)]
710 #[test]
711 fn secure_read_rejects_leaf_and_parent_symlinks_and_non_regular_files() {
712 let _env = crate::test_support::lock_test_env();
713 use std::os::unix::fs::symlink;
714
715 let dir = tempfile::tempdir().expect("tempdir");
716 let root = dir.path().canonicalize().expect("canonical temp root");
717 let real_dir = root.join("real");
718 std::fs::create_dir(&real_dir).expect("real dir");
719 let real = real_dir.join("auth.json");
720 std::fs::write(&real, "secret").expect("fixture");
721
722 let leaf = root.join("leaf.json");
723 symlink(&real, &leaf).expect("leaf symlink");
724 assert!(read_to_string(&grant(&leaf)).is_err());
725
726 let parent = root.join("linked-parent");
727 symlink(&real_dir, &parent).expect("parent symlink");
728 assert!(read_to_string(&grant(&parent.join("auth.json"))).is_err());
729
730 assert!(read_to_string(&grant(&real_dir)).is_err());
731 }
732
733 #[cfg(unix)]
734 #[test]
735 fn secure_read_rejects_a_leaf_swapped_after_grant_before_open() {
736 let _env = crate::test_support::lock_test_env();
737 use std::os::unix::fs::symlink;
738
739 let dir = tempfile::tempdir().expect("tempdir");
740 let root = dir.path().canonicalize().expect("canonical temp root");
741 let path = root.join("auth.json");
742 let moved = root.join("auth-before-swap.json");
743 let attacker = root.join("attacker.json");
744 std::fs::write(&path, "owner-a").expect("owner fixture");
745 std::fs::write(&attacker, "attacker").expect("attacker fixture");
746 let grant = grant(&path);
747 let hook_path = path.clone();
748 BEFORE_LEAF_OPEN_HOOK.with(|hook| {
749 *hook.borrow_mut() = Some(Box::new(move || {
750 std::fs::rename(&hook_path, &moved).expect("move original");
751 symlink(&attacker, &hook_path).expect("swap leaf to symlink");
752 }));
753 });
754 assert!(
755 read_to_string(&grant).is_err(),
756 "a swap to a symlink must fail before any bytes are read"
757 );
758 }
759
760 #[test]
761 fn secure_read_rejects_oversized_regular_file() {
762 let _env = crate::test_support::lock_test_env();
763 let dir = tempfile::tempdir().expect("tempdir");
764 let path = dir
765 .path()
766 .canonicalize()
767 .expect("canonical temp root")
768 .join("oversized.json");
769 let file = File::create(&path).expect("fixture");
770 file.set_len(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
771 .expect("oversize fixture");
772 let error = read_to_string(&grant(&path)).expect_err("oversized file");
773 assert!(error.to_string().contains("safety limit"), "{error:#}");
774 }
775
776 #[cfg(unix)]
777 #[test]
778 fn owned_read_requires_owner_only_regular_file_and_never_follows_symlinks() {
779 use std::os::unix::fs::{PermissionsExt as _, symlink};
780
781 let _env = crate::test_support::lock_test_env();
782 let dir = tempfile::tempdir().expect("tempdir");
783 let root = dir.path().canonicalize().expect("canonical temp root");
784 let path = root.join("owned.json");
785 std::fs::write(&path, "owned-secret").expect("fixture");
786 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644))
787 .expect("loose mode");
788 assert!(
789 read_codewhale_owned_to_string(&path).is_err(),
790 "group/other-readable owned credentials must fail closed"
791 );
792
793 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
794 .expect("owner-only mode");
795 assert_eq!(
796 read_codewhale_owned_to_string(&path)
797 .expect("secure owned read")
798 .as_deref(),
799 Some("owned-secret")
800 );
801
802 let hardlink = root.join("owned-hardlink.json");
803 std::fs::hard_link(&path, &hardlink).expect("hardlink fixture");
804 assert!(
805 read_codewhale_owned_to_string(&path).is_err(),
806 "owned reads must reject multiply-linked files"
807 );
808 std::fs::remove_file(hardlink).expect("remove hardlink fixture");
809
810 let link = root.join("owned-link.json");
811 symlink(&path, &link).expect("symlink");
812 assert!(read_codewhale_owned_to_string(&link).is_err());
813 }
814
815 #[cfg(unix)]
816 #[test]
817 fn owned_read_is_bounded() {
818 use std::os::unix::fs::PermissionsExt as _;
819
820 let _env = crate::test_support::lock_test_env();
821 let dir = tempfile::tempdir().expect("tempdir");
822 let path = dir
823 .path()
824 .canonicalize()
825 .unwrap()
826 .join("oversized-owned.json");
827 let file = File::create(&path).expect("fixture");
828 file.set_len(MAX_EXTERNAL_CREDENTIAL_BYTES + 1).unwrap();
829 file.set_permissions(std::fs::Permissions::from_mode(0o600))
830 .unwrap();
831 let error = read_codewhale_owned_to_string(&path).expect_err("oversized owned file");
832 assert!(error.to_string().contains("safety limit"), "{error:#}");
833 }
834
835 #[cfg(windows)]
836 fn secured_owned_windows_fixture(contents: &[u8]) -> (tempfile::TempDir, std::path::PathBuf) {
837 let dir = tempfile::tempdir().expect("tempdir");
838 secure_codewhale_owned_windows_path(dir.path(), true).expect("owner-only directory");
839 let path = dir.path().join("owned.json");
840 std::fs::write(&path, contents).expect("fixture");
841 secure_codewhale_owned_windows_path(&path, false).expect("owner-only file");
842 (dir, path)
843 }
844
845 #[cfg(windows)]
846 #[test]
847 fn owned_read_accepts_current_user_only_dacl_with_opened_path_spelling() {
848 let _env = crate::test_support::lock_test_env();
849 let (_dir, path) = secured_owned_windows_fixture(b"owned-secret");
850 assert_eq!(
851 read_codewhale_owned_to_string(&path)
852 .expect("secure owned read")
853 .as_deref(),
854 Some("owned-secret")
855 );
856 }
857
858 #[cfg(windows)]
859 #[test]
860 fn owned_read_rejects_hardlinks_on_windows() {
861 let _env = crate::test_support::lock_test_env();
862 let (dir, path) = secured_owned_windows_fixture(b"owned-secret");
863 let hardlink = dir.path().join("owned-hardlink.json");
864 std::fs::hard_link(&path, &hardlink).expect("hardlink fixture");
865 assert!(
866 read_codewhale_owned_to_string(&path).is_err(),
867 "owned reads must reject multiply-linked files"
868 );
869 std::fs::remove_file(hardlink).expect("remove hardlink fixture");
870 }
871
872 #[cfg(windows)]
873 #[test]
874 fn owned_read_is_bounded_on_windows() {
875 let _env = crate::test_support::lock_test_env();
876 let (_dir, path) = secured_owned_windows_fixture(b"owned-secret");
877 let file = File::options()
878 .write(true)
879 .open(&path)
880 .expect("reopen fixture");
881 file.set_len(MAX_EXTERNAL_CREDENTIAL_BYTES + 1)
882 .expect("oversize fixture");
883 let error = read_codewhale_owned_to_string(&path).expect_err("oversized owned file");
884 assert!(error.to_string().contains("safety limit"), "{error:#}");
885 }
886
887 #[cfg(windows)]
888 #[test]
889 fn owned_read_rejects_leaf_reparse_points_on_windows() {
890 let _env = crate::test_support::lock_test_env();
891 let (dir, path) = secured_owned_windows_fixture(b"owned-secret");
892 let link = dir.path().join("owned-link.json");
893 if std::os::windows::fs::symlink_file(&path, &link).is_ok() {
894 assert!(
895 read_codewhale_owned_to_string(&link).is_err(),
896 "owned reads must reject leaf reparse points"
897 );
898 }
899 }
900
901 #[cfg(windows)]
902 #[test]
903 fn windows_handle_path_comparison_is_lossless_and_fails_closed() {
904 use std::ffi::OsString;
905 use std::os::windows::ffi::OsStringExt as _;
906 use std::path::PathBuf;
907
908 let expected = PathBuf::from(r"C:\Users\Alice\credential.json");
909 let kernel = PathBuf::from(r"\\?\C:\Users\Alice\credential.json");
910 assert_eq!(
911 normalize_windows_path_for_comparison(&expected).unwrap(),
912 normalize_windows_path_for_comparison(&kernel).unwrap()
913 );
914 assert_eq!(
915 normalize_windows_path_for_comparison(Path::new(r"C:\Users\Alice\A\credential.json"))
916 .unwrap(),
917 normalize_windows_path_for_comparison(Path::new(r"C:\Users\Alice\a\credential.json"))
918 .unwrap(),
919 "Windows credential path identity must compare case-insensitively"
920 );
921
922 let invalid = PathBuf::from(OsString::from_wide(&[
923 b'C' as u16,
924 b':' as u16,
925 b'\\' as u16,
926 0xd800,
927 ]));
928 assert!(normalize_windows_path_for_comparison(&invalid).is_err());
929 }
930 }
931
931 lines RUST