gatewayd: read a secret file from the file that was checked
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
//! file (M4a spec, section 4; the brief after P15). A `Secret` cannot be printed.
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::path::Path;
|
||||
|
||||
@@ -62,7 +63,8 @@ pub fn load(
|
||||
) -> Result<Loaded, SecretError> {
|
||||
// By source (spec section 4). Credential: read $CREDENTIALS_DIRECTORY/<name> (through `env`,
|
||||
// not std::env); an unset variable, or a file that cannot be read, is an error. Env: the
|
||||
// variable, UTF-8; unset is an error. File: `check_file` first, then read it, and set `warning`
|
||||
// variable, UTF-8; unset is an error. File: `read_checked` checks the path, opens and re-checks
|
||||
// the same file handle, reads it, and set `warning`
|
||||
// to the exact text in the task. Every value goes through `value`. Every error is a
|
||||
// `SecretError` naming the secret, never the value.
|
||||
match source {
|
||||
@@ -122,22 +124,16 @@ pub fn load(
|
||||
})
|
||||
}
|
||||
SecretSource::File(path) => {
|
||||
if let Err(why) = check_file(path) {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
});
|
||||
}
|
||||
let bytes = match std::fs::read(path) {
|
||||
let mut bytes = match read_checked(path, &|| {}) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
Err(why) => {
|
||||
return Err(SecretError {
|
||||
name: name.to_string(),
|
||||
why: format!("cannot read {}: {}", path.display(), e),
|
||||
why,
|
||||
});
|
||||
}
|
||||
};
|
||||
let secret = value(bytes).map_err(|why| SecretError {
|
||||
let secret = value(std::mem::take(&mut *bytes)).map_err(|why| SecretError {
|
||||
name: name.to_string(),
|
||||
why,
|
||||
})?;
|
||||
@@ -153,31 +149,54 @@ pub fn load(
|
||||
}
|
||||
}
|
||||
|
||||
/// An owner-only regular file, not a link.
|
||||
fn check_file(path: &Path) -> Result<(), String> {
|
||||
// In this order, each its own error: not absolute; `symlink_metadata` fails; a symbolic link;
|
||||
// not a regular file; owner uid differs from the uid of /proc/self; mode & 0o077 != 0.
|
||||
/// The bytes of an owner-only regular file, read from the very file that was checked. `between`
|
||||
/// runs after the path is checked and before it is opened: `load` passes `&|| {}`; tests use it
|
||||
/// to swap the file.
|
||||
pub fn read_checked(path: &Path, between: &dyn Fn()) -> Result<Zeroizing<Vec<u8>>, String> {
|
||||
// 1. `path.is_absolute()`, else "<path> is not an absolute path".
|
||||
// 2. `let named = std::fs::symlink_metadata(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `named.file_type().is_symlink()` is "<path> is a symbolic link";
|
||||
// `!named.file_type().is_file()` is "<path> is not a regular file".
|
||||
// 3. `between();`
|
||||
// 4. `let mut file = std::fs::File::open(path)`, an Err(e) is "cannot read <path>: <e>";
|
||||
// `let opened = file.metadata()`, the same error text.
|
||||
// 5. `if (opened.dev(), opened.ino()) != (named.dev(), named.ino())`:
|
||||
// "<path> changed while it was read".
|
||||
// 6. The owner and mode checks from the original file check, word for word, on `opened` (not
|
||||
// on `named`): the uid of "/proc/self"; "<path> is not owned by the user gatewayd runs as";
|
||||
// "<path> has mode <mode:03o>; only the owner may read it (0600 or 0400)".
|
||||
// 7. `let mut bytes = Zeroizing::new(Vec::new());` then `file.read_to_end(&mut bytes)`, an
|
||||
// Err(e) is "cannot read <path>: <e>". Ok(bytes).
|
||||
if !path.is_absolute() {
|
||||
return Err(format!("{} is not an absolute path", path.display()));
|
||||
}
|
||||
let meta = std::fs::symlink_metadata(path)
|
||||
let named = std::fs::symlink_metadata(path)
|
||||
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
if meta.file_type().is_symlink() {
|
||||
if named.file_type().is_symlink() {
|
||||
return Err(format!("{} is a symbolic link", path.display()));
|
||||
}
|
||||
if !meta.file_type().is_file() {
|
||||
if !named.file_type().is_file() {
|
||||
return Err(format!("{} is not a regular file", path.display()));
|
||||
}
|
||||
between();
|
||||
let mut file =
|
||||
std::fs::File::open(path).map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
let opened = file
|
||||
.metadata()
|
||||
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
if (opened.dev(), opened.ino()) != (named.dev(), named.ino()) {
|
||||
return Err(format!("{} changed while it was read", path.display()));
|
||||
}
|
||||
let owner = std::fs::metadata("/proc/self")
|
||||
.map_err(|e| format!("cannot read /proc/self: {}", e))?
|
||||
.uid();
|
||||
if meta.uid() != owner {
|
||||
if opened.uid() != owner {
|
||||
return Err(format!(
|
||||
"{} is not owned by the user gatewayd runs as",
|
||||
path.display()
|
||||
));
|
||||
}
|
||||
let mode = meta.mode() & 0o777;
|
||||
let mode = opened.mode() & 0o777;
|
||||
if mode & 0o077 != 0 {
|
||||
return Err(format!(
|
||||
"{} has mode {:03o}; only the owner may read it (0600 or 0400)",
|
||||
@@ -185,7 +204,10 @@ fn check_file(path: &Path) -> Result<(), String> {
|
||||
mode
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
let mut bytes = Zeroizing::new(Vec::new());
|
||||
file.read_to_end(&mut bytes)
|
||||
.map_err(|e| format!("cannot read {}: {}", path.display(), e))?;
|
||||
Ok(bytes)
|
||||
}
|
||||
|
||||
/// The text without one trailing newline; not empty; UTF-8.
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//! A secret file is read from the file that was checked, never from whatever the path names a
|
||||
//! moment later (M4a review, finding 2). `read_checked` runs `between` after checking the path and
|
||||
//! before opening it; each test swaps something there. Do not edit.
|
||||
|
||||
#[path = "support/tmp.rs"]
|
||||
mod tmp;
|
||||
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use gatewayd::secrets::read_checked;
|
||||
use tmp::TempDir;
|
||||
|
||||
const TOKEN: &str = "the-real-token";
|
||||
const OTHER: &str = "a-file-the-owner-never-chose";
|
||||
|
||||
fn owner_file(dir: &TempDir, name: &str, text: &str) -> PathBuf {
|
||||
let path = dir.write(name, text);
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
path
|
||||
}
|
||||
|
||||
fn refused(path: &Path, between: &dyn Fn(), word: &str) {
|
||||
let why = read_checked(path, between).expect_err(word);
|
||||
assert!(why.contains(word), "{word}: {why}");
|
||||
assert!(why.contains(&path.display().to_string()), "{why}");
|
||||
assert!(
|
||||
!why.contains(TOKEN) && !why.contains(OTHER),
|
||||
"never a value: {why}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_untouched_file_is_read() {
|
||||
let dir = TempDir::new("race-ok");
|
||||
let path = owner_file(&dir, "token", &format!("{TOKEN}\n"));
|
||||
let bytes = read_checked(&path, &|| {}).unwrap();
|
||||
assert_eq!(bytes.as_slice(), format!("{TOKEN}\n").as_bytes());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_swapped_for_a_link_is_refused() {
|
||||
let dir = TempDir::new("race-link");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let swap = || {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
std::os::unix::fs::symlink(&other, &path).unwrap();
|
||||
};
|
||||
refused(&path, &swap, "changed while it was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_file_swapped_for_another_file_is_refused() {
|
||||
let dir = TempDir::new("race-rename");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let swap = || std::fs::rename(&other, &path).unwrap();
|
||||
refused(&path, &swap, "changed while it was read");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_checks_hold_for_the_file_that_is_read() {
|
||||
let dir = TempDir::new("race-mode");
|
||||
let path = owner_file(&dir, "token", TOKEN);
|
||||
let widen = || {
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
};
|
||||
refused(&path, &widen, "has mode 644");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_path_checks_still_come_first() {
|
||||
let dir = TempDir::new("race-first");
|
||||
let other = owner_file(&dir, "other", OTHER);
|
||||
let link = dir.path().join("link");
|
||||
std::os::unix::fs::symlink(&other, &link).unwrap();
|
||||
refused(
|
||||
&link,
|
||||
&|| panic!("never reached for a link"),
|
||||
"is a symbolic link",
|
||||
);
|
||||
refused(
|
||||
dir.path(),
|
||||
&|| panic!("never reached for a directory"),
|
||||
"is not a regular file",
|
||||
);
|
||||
let relative = Path::new("relative/token");
|
||||
let why = read_checked(relative, &|| panic!("never reached")).unwrap_err();
|
||||
assert!(why.contains("is not an absolute path"), "{why}");
|
||||
}
|
||||
Reference in New Issue
Block a user