use toml::Value;
use super::{app_name, apps};
use crate::error::Error;
use crate::shared::printable;
pub(super) fn refuse_repo_privilege(committed: &Value) -> Result<(), Error> {
for app in apps(committed) {
let Some(table) = app.as_table() else {
continue;
};
let name = table
.get("name")
.and_then(Value::as_str)
.map(printable)
.unwrap_or_else(|| "<unnamed>".to_owned());
let refuse = |field: &str, why: &str| {
Error::Config(format!(
"Flockfile.toml sets `{field}` on app {name:?}: {why}. A committed Flockfile \
can never set it, on any app, because the shepherd acts on it at its own uid \
and a build cannot reach that on its own. Pin `{field}` in \
Flockfile.override.toml instead - it is gitignored and never comes from the \
repo."
))
};
for field in ["user", "group"] {
if table.contains_key(field) {
return Err(refuse(
field,
"privilege is the one thing a compromised build cannot escalate to",
));
}
}
for field in ["out_file", "err_file"] {
if table.contains_key(field) {
return Err(refuse(
field,
"the shepherd opens, appends to and on `shep flush` truncates that path \
itself, at its own uid, with no check on where it points",
));
}
}
if table.contains_key("watch") {
return Err(refuse(
"watch",
"the shepherd watches the app's whole working directory itself, at its own \
uid, following symlinks, so a committed link out of the release has it walk \
and watch whatever the link points at",
));
}
for field in ["readiness_probe", "liveness_probe"] {
let Some(value) = table.get(field) else {
continue;
};
let Some(probe) = value.as_table() else {
return Err(refuse(
field,
"a probe the committed file spells as anything but a table is one this \
check cannot read, and shep would accept it",
));
};
let (Some(kind), Some(target)) = (
probe.get("kind").and_then(Value::as_str),
probe.get("target").and_then(Value::as_str),
) else {
return Err(refuse(
field,
"a probe whose `kind` or `target` is not a string is one this check \
cannot read",
));
};
if kind == "exec" {
return Err(refuse(
field,
"an exec probe is run by the shepherd through `sh -c` on the probe's \
interval, forever, at the shepherd's own uid and ignoring `user`",
));
}
if !probes_loopback(kind, target) {
return Err(refuse(
field,
"the shepherd connects to the probe's target from its own network \
position, so a committed target has to be on loopback: `127.0.0.1`, \
`::1` or `localhost`",
));
}
}
}
Ok(())
}
fn probes_loopback(kind: &str, target: &str) -> bool {
let host_and_rest = match kind {
"http" => target
.split_once("://")
.map_or(target, |(_, rest)| rest)
.split(['/', '?', '#'])
.next()
.unwrap_or(""),
"tcp" => target,
_ => return false,
};
let host = host_and_rest.rsplit('@').next().unwrap_or("");
let host = if let Some(rest) = host.strip_prefix('[') {
rest.split(']').next().unwrap_or("")
} else {
host.rsplit_once(':').map_or(host, |(h, port)| {
if port.chars().all(|c| c.is_ascii_digit()) {
h
} else {
host
}
})
};
host.eq_ignore_ascii_case("localhost")
|| host == "::1"
|| host.starts_with("127.")
&& host.split('.').count() == 4
&& host.split('.').all(|octet| octet.parse::<u8>().is_ok())
}
pub(super) fn refuse_duplicate_apps(committed: &Value) -> Result<(), Error> {
let mut seen = std::collections::BTreeSet::new();
for app in apps(committed) {
if let Some(name) = app_name(app)
&& !seen.insert(name)
{
return Err(Error::Config(format!(
"Flockfile.toml declares an app named {:?} more than once; one entry per name",
printable(name)
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fixtures;
use crate::flockfile::app_config;
#[test]
fn a_committed_exec_probe_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n[app.readiness_probe]\nkind = \
\"exec\"\ntarget = \"curl -fs http://127.0.0.1/health\"\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("an exec probe");
let shown = err.to_string();
assert!(
shown.contains("readiness_probe") && shown.contains("sh -c"),
"{shown}"
);
}
#[test]
fn a_committed_probe_spelled_as_a_sequence_is_refused() {
for probe in [
"readiness_probe = [\"exec\", \"id > /tmp/pwn\"]",
"readiness_probe = [\"http\", \"http://metadata.internal/\"]",
"liveness_probe = \"exec\"",
] {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
format!("[[app]]\nname = \"web\"\nscript = \"./run\"\n{probe}\n"),
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err(probe);
assert!(err.to_string().contains("_probe"), "{probe}: {err}");
}
}
#[test]
fn a_committed_watch_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\nwatch = true\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("watch");
assert!(err.to_string().contains("`watch`"), "{err}");
}
#[test]
fn a_committed_log_path_is_refused() {
for field in ["out_file", "err_file"] {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
format!(
"[[app]]\nname = \"web\"\nscript = \"./run\"\n{field} = \"/etc/cron.d/x\"\n"
),
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err(field);
let shown = err.to_string();
assert!(
shown.contains(field) && shown.contains("truncates"),
"{shown}"
);
}
}
#[test]
fn a_committed_network_probe_must_target_loopback() {
let refused = [
("http", "http://metadata.internal/latest/meta-data"),
("http", "http://internal.example:8080/"),
("tcp", "192.0.2.5:5432"),
("tcp", "db:5432"),
("http", "http://127.0.0.1.evil.example/"),
("http", "http://localhost@evil.example/"),
];
for (kind, target) in refused {
assert!(
!probes_loopback(kind, target),
"{kind} {target} must be refused"
);
}
let allowed = [
("http", "http://127.0.0.1:3000/health"),
("http", "http://localhost/health?deep=1"),
("http", "https://[::1]:8443/"),
("http", "http://user:pw@127.0.0.1/"),
("tcp", "127.0.0.1:5432"),
("tcp", "localhost:6379"),
("tcp", "[::1]:80"),
];
for (kind, target) in allowed {
assert!(
probes_loopback(kind, target),
"{kind} {target} must be allowed"
);
}
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n[app.readiness_probe]\nkind = \
\"http\"\ntarget = \"http://metadata.internal/\"\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("off-host target");
assert!(err.to_string().contains("loopback"), "{err}");
}
#[test]
fn a_committed_flockfile_naming_an_app_twice_is_refused() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./a\"\n[[app]]\nname = \"web\"\nscript = \"./b\"\n",
)
.expect("committed");
let err = app_config(rel.path(), "web", &[]).expect_err("two apps named web");
let shown = err.to_string();
assert!(
shown.contains("web") && shown.contains("more than once"),
"{shown}"
);
}
#[test]
fn a_committed_override_cannot_grant_a_user() {
let rel = tempfile::tempdir().expect("tempdir");
std::fs::write(
rel.path().join("Flockfile.toml"),
"[[app]]\nname = \"web\"\nscript = \"./run\"\n",
)
.expect("committed");
std::fs::write(
rel.path().join("Flockfile.override.toml"),
"[[app]]\nname = \"web\"\nuser = \"root\"\n",
)
.expect("committed override");
let err = app_config(rel.path(), "web", &[])
.expect_err("a committed override must not grant a user");
assert!(
format!("{err}").contains("user"),
"the refusal must name the field: {err}"
);
}
#[test]
fn a_committed_flockfile_cannot_set_user() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\nuser='root'\n",
)]);
let err = app_config(rel.path(), "web", &[]).expect_err("refuses");
assert!(err.to_string().contains("user"));
}
#[test]
fn a_committed_flockfile_cannot_set_group() {
let rel = fixtures::fixture_release(&[(
"Flockfile.toml",
"[[app]]\nname='web'\nscript='x.js'\ngroup='wheel'\n",
)]);
let err = app_config(rel.path(), "web", &[]).expect_err("refuses");
assert!(err.to_string().contains("group"));
}
}