Skip to content

Commit cfca8d2

Browse files
committed
credential: reject seekable stdin, validate header names, honor fs-deny, match decoded query params
Signed-off-by: Cong Wang <cwang@multikernel.io>
1 parent 5463bdb commit cfca8d2

2 files changed

Lines changed: 205 additions & 25 deletions

File tree

crates/sandlock-core/src/credential.rs

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -123,10 +123,12 @@ fn read_capped<R: std::io::Read>(r: &mut R, what: &str) -> Result<Vec<u8>, Sandb
123123

124124
/// Read a credential from an already-open fd (e.g. a shell `<(...)` process
125125
/// substitution passed as `fd:N`, or the secret piped on stdin as `fd:0`). Reads
126-
/// through a *dup*, so the caller's fd is left open. `fd:0` (stdin) is allowed —
127-
/// it's the most portable secret-passing pattern (`printf %s "$SECRET" | sandlock
128-
/// … --credential k=fd:0`, docker `-i`, systemd credential fds) — but `fd:1`/`fd:2`
129-
/// are refused so a typo can't close/consume stdout/stderr.
126+
/// through a *dup*, so the caller's fd is left open. `fd:1`/`fd:2` are refused so
127+
/// a typo can't close/consume stdout/stderr. `fd:0` (stdin) is allowed for the
128+
/// portable pipe pattern (`printf %s "$SECRET" | sandlock … --credential k=fd:0`,
129+
/// docker `-i`, systemd credential fds), but only when it cannot be rewound:
130+
/// unlike higher fds, stdin survives into the child, and a seekable stdin (a
131+
/// `< secret.txt` redirect) would let the child lseek back and read the secret.
130132
fn read_fd(n: i32) -> Result<Vec<u8>, SandboxError> {
131133
use std::os::fd::FromRawFd;
132134
if n == 1 || n == 2 {
@@ -143,9 +145,25 @@ fn read_fd(n: i32) -> Result<Vec<u8>, SandboxError> {
143145
}
144146
// Owns `dup` (not `n`), so only the dup is closed on drop.
145147
let mut f = unsafe { std::fs::File::from_raw_fd(dup) };
148+
if n == 0 && fd_is_rewindable(&mut f) {
149+
return Err(SandboxError::Invalid(
150+
"credential fd 0 (stdin) is seekable, so the sandboxed child could rewind \
151+
it and re-read the secret; pipe it instead (printf %s \"$SECRET\" | sandlock …)"
152+
.into(),
153+
));
154+
}
146155
read_capped(&mut f, &format!("fd {n}"))
147156
}
148157

158+
/// Whether the fd behind `f` can be rewound. Probing the dup is sound because a
159+
/// dup shares the file offset with the original; for the same reason, draining a
160+
/// seekable stdin supervisor-side would not protect the secret. A pipe/socket/tty
161+
/// cannot rewind (lseek fails with ESPIPE).
162+
fn fd_is_rewindable(f: &mut std::fs::File) -> bool {
163+
use std::io::Seek;
164+
f.stream_position().is_ok()
165+
}
166+
149167
/// How the secret is attached to a matching request.
150168
#[derive(Debug, Clone, PartialEq, Eq)]
151169
pub enum AuthShape {
@@ -283,8 +301,12 @@ impl InjectRule {
283301
// — no `=`, which `starts_with("key=")` would miss — is still recognised
284302
// as the target: otherwise Replace would append `key&key=secret`, and a
285303
// first-occurrence-reading upstream would authenticate with the child's
286-
// empty value instead of the injected one.
287-
let is_target = |kv: &str| kv.split('=').next().unwrap_or(kv) == enc;
304+
// empty value instead of the injected one. Compare names DECODED, so a
305+
// child spelling the name with stray percent-encoding (`%6Bey=` for
306+
// `key=`) still counts as the target instead of evading the filter.
307+
let is_target = |kv: &str| {
308+
percent_decode_lossy(kv.split('=').next().unwrap_or(kv)) == param.as_bytes()
309+
};
288310
// Honor AddOnly: don't append a param the request already carries.
289311
if self.on_existing == OnExistingHeader::AddOnly {
290312
if let Some(q) = existing {
@@ -341,8 +363,17 @@ pub fn parse_auth(spec: &str, credential: &str) -> Result<AuthShape, SandboxErro
341363
)))
342364
}
343365
("basic", Some(user)) if !user.is_empty() => AuthShape::Basic { username: user.to_string() },
344-
// `apikey:<header>` and `header:<name>` are the same rendering.
345-
("header" | "apikey", Some(name)) if !name.is_empty() => AuthShape::Header { name: name.to_string() },
366+
// `apikey:<header>` and `header:<name>` are the same rendering. Validate
367+
// the name here so a typo fails at build time; unchecked, it would only
368+
// surface as a per-request 502 when `apply` first renders the header.
369+
("header" | "apikey", Some(name)) if !name.is_empty() => {
370+
if hyper::header::HeaderName::from_bytes(name.as_bytes()).is_err() {
371+
return Err(SandboxError::Invalid(format!(
372+
"invalid header name {name:?} in auth shape {spec:?} for credential {credential:?}"
373+
)));
374+
}
375+
AuthShape::Header { name: name.to_string() }
376+
}
346377
("query", Some(param)) if !param.is_empty() => AuthShape::Query { param: param.to_string() },
347378
_ => {
348379
return Err(SandboxError::Invalid(format!(
@@ -480,6 +511,28 @@ fn base64_encode(input: &[u8]) -> String {
480511
out
481512
}
482513

514+
/// Decode `%XX` escapes in a query token to raw bytes (an invalid escape is kept
515+
/// literally), so two spellings of the same param name compare equal.
516+
fn percent_decode_lossy(s: &str) -> Vec<u8> {
517+
let b = s.as_bytes();
518+
let mut out = Vec::with_capacity(b.len());
519+
let mut i = 0;
520+
while i < b.len() {
521+
if b[i] == b'%' && i + 2 < b.len() {
522+
let hi = (b[i + 1] as char).to_digit(16);
523+
let lo = (b[i + 2] as char).to_digit(16);
524+
if let (Some(hi), Some(lo)) = (hi, lo) {
525+
out.push((hi as u8) << 4 | lo as u8);
526+
i += 3;
527+
continue;
528+
}
529+
}
530+
out.push(b[i]);
531+
i += 1;
532+
}
533+
out
534+
}
535+
483536
/// Percent-encode raw bytes as a query component (encode everything not
484537
/// unreserved, so `&`/`=`/`#`/`%` and any binary byte can't break out).
485538
fn urlencode_bytes(bytes: &[u8]) -> String {
@@ -695,6 +748,85 @@ mod tests {
695748
assert!(load_secret("fd:2").is_err()); // stderr (fd:0/stdin is now allowed)
696749
}
697750

751+
#[test]
752+
fn fd0_rejects_seekable_stdin_but_allows_pipe() {
753+
use std::os::fd::IntoRawFd;
754+
// A seekable stdin (file redirect) must be refused: stdin survives into
755+
// the child, which could lseek back and re-read the secret. A pipe on
756+
// stdin (the documented pattern) can't rewind and stays allowed. Lib
757+
// tests run single-threaded in CI, and stdin is restored either way.
758+
let path = std::env::temp_dir()
759+
.join(format!("sandlock-cred-fd0-{}", std::process::id()));
760+
std::fs::write(&path, "sk-file\n").unwrap();
761+
let file_fd = std::fs::File::open(&path).unwrap().into_raw_fd();
762+
let saved = unsafe { libc::dup(0) };
763+
assert!(saved >= 0);
764+
765+
assert_eq!(unsafe { libc::dup2(file_fd, 0) }, 0);
766+
let res_file = load_secret("fd:0");
767+
768+
let mut fds = [0i32; 2];
769+
assert_eq!(unsafe { libc::pipe(fds.as_mut_ptr()) }, 0);
770+
unsafe {
771+
assert_eq!(libc::write(fds[1], b"sk-pipe\n".as_ptr().cast(), 8), 8);
772+
libc::close(fds[1]);
773+
libc::dup2(fds[0], 0);
774+
libc::close(fds[0]);
775+
}
776+
let res_pipe = load_secret("fd:0");
777+
778+
unsafe {
779+
libc::dup2(saved, 0); // restore the real stdin before asserting
780+
libc::close(saved);
781+
libc::close(file_fd);
782+
}
783+
let _ = std::fs::remove_file(&path);
784+
785+
let err = res_file.expect_err("seekable stdin must be rejected");
786+
assert!(err.to_string().contains("pipe it instead"), "got: {err}");
787+
assert_eq!(res_pipe.unwrap().expose(), b"sk-pipe");
788+
}
789+
790+
#[test]
791+
fn fd_above_two_may_be_seekable() {
792+
use std::os::fd::IntoRawFd;
793+
// Fds above 2 are closed in the child post-fork, so a seekable file fd
794+
// is safe there; only fd 0 carries the rewind restriction.
795+
let path = std::env::temp_dir()
796+
.join(format!("sandlock-cred-fdn-{}", std::process::id()));
797+
std::fs::write(&path, "sk-n").unwrap();
798+
let fd = std::fs::File::open(&path).unwrap().into_raw_fd();
799+
let s = load_secret(&format!("fd:{fd}")).unwrap();
800+
assert_eq!(s.expose(), b"sk-n");
801+
unsafe { libc::close(fd) };
802+
let _ = std::fs::remove_file(&path);
803+
}
804+
805+
#[test]
806+
fn parse_auth_rejects_invalid_header_name_at_build_time() {
807+
// A malformed header name must fail when the rule is parsed, not as a
808+
// per-request 502 the first time the rule fires.
809+
assert!(parse_auth("header:bad name", "c").is_err());
810+
assert!(parse_auth("apikey:x:y", "c").is_err());
811+
assert!(parse_auth("header:x-ok", "c").is_ok());
812+
}
813+
814+
#[test]
815+
fn query_encoded_spelling_of_param_is_still_target() {
816+
// `%6Bey` decodes to `key`: Replace must treat it as the target and drop
817+
// it, not leave the child's pair alongside the injected one.
818+
let mut p = parts_of("https://api.example.com/x?%6Bey=child&a=1", &[]);
819+
rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::Replace)
820+
.apply(&mut p).unwrap();
821+
assert_eq!(p.uri.query().unwrap(), "a=1&key=sk-real");
822+
823+
// AddOnly sees the encoded spelling as present and keeps the child's value.
824+
let mut p2 = parts_of("https://api.example.com/x?%6Bey=child", &[]);
825+
let r = rule(AuthShape::Query { param: "key".into() }, "sk-real", OnExistingHeader::AddOnly);
826+
assert!(matches!(r.apply(&mut p2), Ok(Applied::Skipped)));
827+
assert_eq!(p2.uri.query().unwrap(), "%6Bey=child");
828+
}
829+
698830
#[test]
699831
fn resolve_default_is_replace_add_only_is_opt_in() {
700832
std::env::set_var("SANDLOCK_TEST_ONEX", "sk-x");

crates/sandlock-core/src/sandbox/builder.rs

Lines changed: 65 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -756,33 +756,28 @@ impl SandboxBuilder {
756756
// is often a broad dir the workload needs), so warn on the overlap. Every
757757
// exposing grant is covered: read grants, write grants (write access
758758
// includes read), bind-mounted host dirs, and the chroot root (visible
759-
// regardless of Landlock). Best-effort — canonicalize where possible.
759+
// regardless of Landlock). An fs-deny covering the file suppresses the
760+
// warning, so following its own advice actually silences it.
760761
for c in &self.credentials {
761762
let Some(path) = c.split_once('=').and_then(|(_, s)| s.strip_prefix("file:")) else {
762763
continue;
763764
};
764-
let secret = std::path::Path::new(path);
765-
let secret_abs = secret.canonicalize();
766-
let secret_ref = secret_abs.as_deref().unwrap_or(secret);
767765
let grants = self
768766
.fs_readable
769767
.iter()
770768
.chain(self.fs_writable.iter())
771769
.chain(self.fs_mount.iter().map(|(_, host)| host))
772770
.chain(self.chroot.as_ref());
773-
for grant in grants {
774-
let grant_abs = grant.canonicalize();
775-
let grant_ref = grant_abs.as_deref().unwrap_or(grant.as_path());
776-
if secret_ref.starts_with(grant_ref) {
777-
eprintln!(
778-
"sandlock: warning: credential file {} is inside the sandbox grant {} — \
779-
the sandboxed child can read the secret directly; keep it outside every \
780-
fs grant (or add an fs-deny for it)",
781-
path,
782-
grant.display()
783-
);
784-
break;
785-
}
771+
if let Some(grant) =
772+
exposing_grant(std::path::Path::new(path), grants, &self.fs_denied)
773+
{
774+
eprintln!(
775+
"sandlock: warning: credential file {} is inside the sandbox grant {}; \
776+
the sandboxed child can read the secret directly; keep it outside every \
777+
fs grant (or add an fs-deny for it)",
778+
path,
779+
grant.display()
780+
);
786781
}
787782
}
788783
// Resolve credentials + injection rules, loading each secret into the
@@ -909,3 +904,56 @@ impl SandboxBuilder {
909904
Ok(p)
910905
}
911906
}
907+
908+
/// The first fs grant that exposes `secret` to the sandboxed child, or `None`
909+
/// when no grant reaches it or an fs-deny covers it (the deny closes the hole,
910+
/// so no warning is due). Best-effort: canonicalize where possible.
911+
fn exposing_grant<'a>(
912+
secret: &std::path::Path,
913+
grants: impl Iterator<Item = &'a std::path::PathBuf>,
914+
denies: &[std::path::PathBuf],
915+
) -> Option<std::path::PathBuf> {
916+
let secret_abs = secret.canonicalize();
917+
let secret_ref = secret_abs.as_deref().unwrap_or(secret);
918+
let covered_by = |p: &std::path::PathBuf| {
919+
let abs = p.canonicalize();
920+
secret_ref.starts_with(abs.as_deref().unwrap_or(p.as_path()))
921+
};
922+
if denies.iter().any(&covered_by) {
923+
return None;
924+
}
925+
grants.into_iter().find(|g| covered_by(g)).cloned()
926+
}
927+
928+
#[cfg(test)]
929+
mod tests {
930+
use super::exposing_grant;
931+
use std::path::PathBuf;
932+
933+
#[test]
934+
fn exposing_grant_reports_overlap_and_fs_deny_suppresses() {
935+
let dir = std::env::temp_dir().join(format!("sandlock-grant-{}", std::process::id()));
936+
std::fs::create_dir_all(&dir).unwrap();
937+
let secret = dir.join("key.txt");
938+
std::fs::write(&secret, "s").unwrap();
939+
let grants = vec![dir.clone()];
940+
941+
// Inside a read grant: the exposing grant is reported.
942+
assert_eq!(exposing_grant(&secret, grants.iter(), &[]), Some(dir.clone()));
943+
// An fs-deny on the file itself, or on a covering directory, closes the
944+
// hole: following the warning's own advice must silence it.
945+
assert_eq!(exposing_grant(&secret, grants.iter(), std::slice::from_ref(&secret)), None);
946+
assert_eq!(exposing_grant(&secret, grants.iter(), std::slice::from_ref(&dir)), None);
947+
// A deny elsewhere does not suppress the warning.
948+
assert_eq!(
949+
exposing_grant(&secret, grants.iter(), &[PathBuf::from("/nonexistent-deny")]),
950+
Some(dir.clone())
951+
);
952+
// Outside every grant: nothing to report.
953+
let other = vec![PathBuf::from("/nonexistent-grant")];
954+
assert_eq!(exposing_grant(&secret, other.iter(), &[]), None);
955+
956+
let _ = std::fs::remove_file(&secret);
957+
let _ = std::fs::remove_dir(&dir);
958+
}
959+
}

0 commit comments

Comments
 (0)