Compare commits

...
Author SHA1 Message Date
Dylan M. Taylor d593a8f99d Use existing craft-xml escaping for XLSX output 2026-11-15 10:19:11 -05:00
skylar taylor-barrick 8bde636f27 Sandbox: test the socket owner ioctls with a real pointer (#4245)
The merged test asserts FIOSETOWN and FIOASYNC with the ioctl argument passed
by value, where the kernel reads the number as an address and fails with
EFAULT whether or not the filter refuses it, so the refusal alone is not the
reason the call came back. Passing a pointer, as a C caller does, the call
succeeds unconfined and is EPERM confined.

This covers FIOSETOWN and SIOCSPGRP that way on a socket the parent made and
the program inherited as a standard stream, after reading its socket name so a
pipe cannot pass for one. SIOCSPGRP is in the rules and in the filter's count
assertion but in no test until now.

Deleting either rule line fails this test at that ioctl's assertion; restored,
it passes. The test above is untouched.
2026-11-14 22:04:39 -06:00
5 changed files with 76 additions and 48 deletions
Generated
+1
View File
@@ -2349,6 +2349,7 @@ dependencies = [
"craft-offcrypto",
"craft-opc",
"craft-xls",
"craft-xml",
"gridcraft-core",
"gridcraft-formula",
"gridcraft-functions",
+1
View File
@@ -40,6 +40,7 @@ craft-opc = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0
# turn them into workbooks.
craft-csv = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
# Excel 97-2003 (.xls, BIFF8) workbooks; `crates/xlsx/src/xls.rs` maps them onto the model.
craft-xml = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
craft-xls = { git = "https://github.com/storytold/craft-libs", rev = "bb57d422d0bc842cbde430955825553ab7b976f4", version = "0.1.0" }
# foundation
+54
View File
@@ -292,6 +292,60 @@ print("socket quiet")
assert!(String::from_utf8_lossy(&o.stdout).contains("socket quiet"), "{out}");
}
/// The same inherited socket, by both ioctls that name the process to notify, `FIOSETOWN` (0x8901)
/// and `SIOCSPGRP` (0x8902), with the argument a real pointer. Named by value the kernel reads the
/// number as an address and fails with `EFAULT` whether or not the filter refuses it, so that form
/// shows only that a rule matched a request number; by pointer each call succeeds unconfined and is
/// `EPERM` confined, which is the transition the page claims. Kept apart from the test above so a
/// rule dropped for one request number fails for that request, not behind another. Linux only, as
/// those rules are: macOS's profile has none, and its standard streams here are pipes.
#[cfg(target_os = "linux")]
#[test]
fn a_confined_program_cant_point_an_inherited_socket_at_another_process_by_pointer() {
if !confines() {
return;
}
let Some((python, prefix)) = system_python() else {
return;
};
let code = r#"
import fcntl, socket, struct, sys
other = int(sys.argv[1])
def refused(call, *args):
try:
call(*args)
except PermissionError:
return True
except OSError as e:
print("other error", e)
return False
# Read first, so a standard stream that is not a socket can't pass for one: a pipe has no socket name.
# Detached, not dropped: a socket object closes the descriptor it wrapped when it is collected, and then
# the refusals below would be read off an invalid descriptor.
wrapped = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM, fileno=0)
name = wrapped.getsockname()
wrapped.detach()
assert name == ""
# The process group is a pointer, as a C caller passes it; naming it by value has the kernel read the
# number as an address and fail with EFAULT, which would leave the refusal unexplained by the filter.
group = bytearray(struct.pack("i", other))
assert refused(fcntl.ioctl, 0, 0x8901, group), "giving a socket's notifications to another process"
assert refused(fcntl.ioctl, 0, 0x8902, group), "giving a socket's notifications to another process's group"
print("socket owner quiet")
"#;
let (mine, _) = std::os::unix::net::UnixStream::pair().unwrap();
let socket: std::os::fd::OwnedFd = mine.into();
let me = std::process::id().to_string();
let o = Command::new(env!("CARGO_BIN_EXE_gridcraft-cli"))
.args(["sandbox-check", "--read", &prefix, "--", &python, "-I", "-c", code, &me])
.stdin(std::process::Stdio::from(socket))
.output()
.unwrap();
let out = text(&o);
assert!(o.status.success(), "{out}");
assert!(String::from_utf8_lossy(&o.stdout).contains("socket owner quiet"), "{out}");
}
/// Confined, a program changes no file, whether the policy lets it read that file or not, by path or
/// by a descriptor: not its contents, permissions, owner, extended attributes, times or flags.
/// Skipped when there is no `python3`.
+1
View File
@@ -23,6 +23,7 @@ craft-csv = { workspace = true }
craft-offcrypto = { workspace = true }
craft-opc = { workspace = true }
craft-xls = { workspace = true }
craft-xml = { workspace = true }
thiserror = { workspace = true }
quick-xml = { workspace = true }
zip = { workspace = true }
+19 -48
View File
@@ -359,31 +359,9 @@ pub fn parse_streaming(xml: &[u8], split: &[&str], cb: &mut dyn FnMut(El) -> Res
// ---------------------------------------------------------------- writing helpers
/// Escapes text content.
pub fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len());
push_esc(&mut o, s);
o
}
/// Appends [`esc`]`(s)` to `o`.
pub fn push_esc(o: &mut String, s: &str) {
if s.bytes().all(|b| matches!(b, b'\t' | b'\n' | 0x20..=0x7E) && !matches!(b, b'&' | b'<' | b'>')) {
o.push_str(s);
return;
}
o.reserve(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'\r' => o.push_str("&#13;"),
c if is_xml_char(c) => o.push(c),
_ => {}
}
}
}
// XML 1.0 byte escaping is shared; spreadsheet string and phonetic policy stays here.
use craft_xml::is_xml_char;
pub use craft_xml::{esc, esc_attr, push_esc};
/// Appends `n` in decimal.
pub fn push_u64(o: &mut String, mut n: u64) {
@@ -422,29 +400,6 @@ pub fn push_a1(o: &mut String, c: gridcraft_core::CellRef) {
push_u64(o, u64::from(c.row) + 1);
}
/// Escapes an attribute value (double-quoted).
pub fn esc_attr(s: &str) -> String {
let mut o = String::with_capacity(s.len());
for c in s.chars() {
match c {
'&' => o.push_str("&amp;"),
'<' => o.push_str("&lt;"),
'>' => o.push_str("&gt;"),
'"' => o.push_str("&quot;"),
'\n' => o.push_str("&#10;"),
'\r' => o.push_str("&#13;"),
'\t' => o.push_str("&#9;"),
c if is_xml_char(c) => o.push(c),
_ => {}
}
}
o
}
fn is_xml_char(c: char) -> bool {
matches!(c, '\t' | '\n' | '\r' | '\u{20}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{10000}'..='\u{10FFFF}')
}
/// ST_Xstring encoding for cell text: characters XML can't carry and carriage returns become
/// `_xHHHH_`, and literal `_xHHHH_` sequences are protected with `_x005F_`. The result still needs
/// [`esc`].
@@ -805,3 +760,19 @@ mod tests {
assert_eq!("1.5E20".parse::<f64>().unwrap(), 1.5e20);
}
}
#[cfg(test)]
mod shared_escape_parity {
use super::*;
#[test]
fn xml_content_and_attributes_preserve_grid_bytes() {
let value = "&<>\"'\t\n\r\0\u{1f}\u{fffe}\u{ffff}é😀";
assert_eq!(esc(value), "&amp;&lt;&gt;\"'\t\n&#13;é😀");
assert_eq!(esc_attr(value), "&amp;&lt;&gt;&quot;'&#9;&#10;&#13;é😀");
let mut appended = "prefix:".to_owned();
push_esc(&mut appended, value);
assert_eq!(appended, format!("prefix:{}", esc(value)));
assert_eq!(esc("plain ASCII\t\n"), "plain ASCII\t\n");
assert_eq!(decode_xstring(&encode_xstring("_x000D_\r\0😀")), "_x000D_\r\0😀");
}
}