@@ -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.
130132fn 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 ) ]
151169pub 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).
485538fn 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" ) ;
0 commit comments