#![cfg_attr(
not(test),
deny(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::unreachable,
clippy::todo,
clippy::unimplemented,
clippy::indexing_slicing,
clippy::string_slice,
clippy::arithmetic_side_effects,
)
)]
use std::fmt;
use serde::{Deserialize, Serialize};
use super::manifest::{ManifestError, SandboxCapability};
pub const MAX_GRANT_ENTRIES: usize = 64;
pub const MAX_GRANT_IDENT_LEN: usize = 63;
pub const MAX_HOST_LEN: usize = 253;
pub const MAX_QUOTA: u32 = 1_000_000;
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct CapabilityGrants {
pub hosts: Vec<String>,
pub tables: Vec<String>,
pub job_types: Vec<String>,
pub slots: Vec<String>,
}
type GrantRow<'a> = (&'static str, SandboxCapability, &'a Vec<String>, EntryKind);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EntryKind {
Host,
Ident,
Name,
}
impl CapabilityGrants {
#[must_use]
pub const fn is_empty(&self) -> bool {
self.hosts.is_empty()
&& self.tables.is_empty()
&& self.job_types.is_empty()
&& self.slots.is_empty()
}
const fn rows(&self) -> [GrantRow<'_>; 4] {
[
(
"hosts",
SandboxCapability::HttpOutbound,
&self.hosts,
EntryKind::Host,
),
(
"tables",
SandboxCapability::Db,
&self.tables,
EntryKind::Ident,
),
(
"job_types",
SandboxCapability::Jobs,
&self.job_types,
EntryKind::Name,
),
(
"slots",
SandboxCapability::Render,
&self.slots,
EntryKind::Name,
),
]
}
#[must_use]
pub fn list_for(&self, capability: SandboxCapability) -> Option<&[String]> {
self.rows()
.into_iter()
.find(|(_, cap, ..)| *cap == capability)
.map(|(_, _, list, _)| list.as_slice())
}
#[must_use]
pub fn allows(&self, capability: SandboxCapability, entry: &str) -> bool {
self.list_for(capability)
.is_some_and(|list| list.iter().any(|granted| granted == entry))
}
pub fn validate(&self, granted: &[SandboxCapability]) -> Result<(), ManifestError> {
for (field, capability, list, kind) in self.rows() {
let held = granted.contains(&capability);
if !list.is_empty() && !held {
return Err(ManifestError::GrantWithoutCapability { capability, field });
}
if list.is_empty() && held {
return Err(ManifestError::CapabilityWithoutGrant { capability, field });
}
if list.len() > MAX_GRANT_ENTRIES {
return Err(ManifestError::TooManyGrantEntries {
field,
found: list.len(),
max: MAX_GRANT_ENTRIES,
});
}
for (at, entry) in list.iter().enumerate() {
let legal = match kind {
EntryKind::Host => is_grantable_host(entry),
EntryKind::Ident => is_grantable_ident(entry),
EntryKind::Name => is_grantable_name(entry),
};
if !legal {
return Err(ManifestError::InvalidGrantEntry {
field,
entry: super::manifest::rejected(entry),
reason: match kind {
EntryKind::Host => {
"expected a bare lower-case DNS hostname: no scheme, port, path, \
userinfo or wildcard, and at most 253 bytes"
}
EntryKind::Ident => {
"expected a lower-case identifier matching `[a-z][a-z0-9_]*` of \
at most 63 bytes, because the host derives a physical name from \
it"
}
EntryKind::Name => {
"expected a lower-case name matching `[a-z][a-z0-9_-]*` of at \
most 63 bytes"
}
},
});
}
if list.get(..at).is_some_and(|seen| seen.contains(entry)) {
return Err(ManifestError::DuplicateGrantEntry {
field,
entry: super::manifest::rejected(entry),
});
}
}
}
Ok(())
}
}
#[must_use]
pub fn is_grantable_host(host: &str) -> bool {
if host.is_empty() || host.len() > MAX_HOST_LEN {
return false;
}
if !host.contains('.') {
return false;
}
if host.parse::<std::net::Ipv4Addr>().is_ok() {
return false;
}
if host.rsplit('.').next().is_some_and(looks_like_number) {
return false;
}
host.split('.').all(|label| {
!label.is_empty()
&& label.len() <= 63
&& !label.starts_with('-')
&& !label.ends_with('-')
&& label
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-')
})
}
fn looks_like_number(label: &str) -> bool {
if label.is_empty() {
return false;
}
if let Some(hex) = label.strip_prefix("0x") {
return !hex.is_empty() && hex.bytes().all(|byte| byte.is_ascii_hexdigit());
}
label.bytes().all(|byte| byte.is_ascii_digit())
}
#[must_use]
pub fn is_grantable_ident(ident: &str) -> bool {
!ident.is_empty()
&& ident.len() <= MAX_GRANT_IDENT_LEN
&& ident.starts_with(|ch: char| ch.is_ascii_lowercase())
&& ident
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_')
}
#[must_use]
pub fn is_grantable_name(name: &str) -> bool {
!name.is_empty()
&& name.len() <= MAX_GRANT_IDENT_LEN
&& name.starts_with(|ch: char| ch.is_ascii_lowercase())
&& name.bytes().all(|byte| {
byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'_' | b'-')
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields, default)]
pub struct CapabilityQuotas {
pub kv_reads: u32,
pub kv_writes: u32,
pub kv_value_bytes: u32,
pub outbound_calls: u32,
pub outbound_response_bytes: u32,
pub outbound_request_bytes: u32,
pub db_reads: u32,
pub db_writes: u32,
pub db_rows: u32,
pub job_enqueues: u32,
pub render_bytes: u32,
pub calls: u32,
pub outbound_timeout_ms: u32,
pub calls_per_second: u32,
}
impl Default for CapabilityQuotas {
fn default() -> Self {
Self {
kv_reads: 64,
kv_writes: 32,
kv_value_bytes: 64 * 1024,
outbound_calls: 4,
outbound_response_bytes: 256 * 1024,
outbound_request_bytes: 256 * 1024,
db_reads: 64,
db_writes: 32,
db_rows: 500,
job_enqueues: 8,
render_bytes: 64 * 1024,
calls: 128,
outbound_timeout_ms: 5_000,
calls_per_second: 200,
}
}
}
impl CapabilityQuotas {
#[must_use]
pub const fn fields(&self) -> [(&'static str, u32); 14] {
[
("kv_reads", self.kv_reads),
("kv_writes", self.kv_writes),
("kv_value_bytes", self.kv_value_bytes),
("outbound_calls", self.outbound_calls),
("outbound_response_bytes", self.outbound_response_bytes),
("outbound_request_bytes", self.outbound_request_bytes),
("db_reads", self.db_reads),
("db_writes", self.db_writes),
("db_rows", self.db_rows),
("job_enqueues", self.job_enqueues),
("render_bytes", self.render_bytes),
("calls", self.calls),
("outbound_timeout_ms", self.outbound_timeout_ms),
("calls_per_second", self.calls_per_second),
]
}
#[must_use]
pub fn governed_by(field: &str) -> Option<SandboxCapability> {
match field {
"kv_reads" | "kv_writes" | "kv_value_bytes" => Some(SandboxCapability::Kv),
"outbound_calls"
| "outbound_response_bytes"
| "outbound_request_bytes"
| "outbound_timeout_ms" => Some(SandboxCapability::HttpOutbound),
"db_reads" | "db_writes" | "db_rows" => Some(SandboxCapability::Db),
"job_enqueues" => Some(SandboxCapability::Jobs),
"render_bytes" => Some(SandboxCapability::Render),
_ => None,
}
}
pub fn validate(&self) -> Result<(), ManifestError> {
for (field, value) in self.fields() {
if value == 0 || value > MAX_QUOTA {
return Err(ManifestError::QuotaOutOfRange {
field,
value,
max: MAX_QUOTA,
});
}
}
Ok(())
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[non_exhaustive]
pub struct ConsentDelta {
pub added_capabilities: Vec<SandboxCapability>,
pub added_hosts: Vec<String>,
pub added_tables: Vec<String>,
pub added_job_types: Vec<String>,
pub added_slots: Vec<String>,
pub raised_quotas: Vec<(&'static str, u32, u32)>,
pub added_routes: Vec<String>,
pub raised_limits: Vec<(&'static str, u128, u128)>,
}
impl ConsentDelta {
#[must_use]
pub const fn requires_consent(&self) -> bool {
!self.added_capabilities.is_empty()
|| !self.added_hosts.is_empty()
|| !self.added_tables.is_empty()
|| !self.added_job_types.is_empty()
|| !self.added_slots.is_empty()
|| !self.raised_quotas.is_empty()
|| !self.added_routes.is_empty()
|| !self.raised_limits.is_empty()
}
#[must_use]
pub fn summary(&self) -> String {
use std::fmt::Write as _;
if !self.requires_consent() {
return String::new();
}
let mut out =
String::from("This upgrade asks for authority the installed version did not:\n");
for capability in &self.added_capabilities {
let _ = writeln!(
out,
" + capability {name} — {describe}",
name = capability.as_str(),
describe = capability.describe()
);
}
for (label, entries) in [
("outbound host", &self.added_hosts),
("database table", &self.added_tables),
("job type", &self.added_job_types),
("render slot", &self.added_slots),
] {
for entry in entries {
let _ = writeln!(out, " + {label} {entry}");
}
}
for route in &self.added_routes {
let _ = writeln!(out, " + route {route}");
}
for (field, approved, requested) in &self.raised_quotas {
let _ = writeln!(out, " + quota {field} {approved} -> {requested}");
}
for (field, approved, requested) in &self.raised_limits {
let _ = writeln!(out, " + limit {field} {approved} -> {requested}");
}
out
}
}
#[must_use]
pub(crate) fn added<T: PartialEq + Clone>(previous: &[T], next: &[T]) -> Vec<T> {
next.iter()
.filter(|entry| !previous.contains(entry))
.cloned()
.collect()
}
impl fmt::Display for ConsentDelta {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.summary())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_grantable_host_is_a_bare_lower_case_dns_name() {
for host in [
"api.example.com",
"a.b",
"x1-y2.example.co.uk",
&format!("{}.example.com", "a".repeat(63)),
] {
assert!(is_grantable_host(host), "{host}");
}
for host in [
"",
"localhost",
"169.254.169.254",
"127.0.0.1",
"10.0.0.1",
"0.0.0.0",
"255.255.255.255",
"API.example.com",
"api.example.com.",
".example.com",
"api..example.com",
"-api.example.com",
"api-.example.com",
"api.example.com:443",
"https://api.example.com",
"api.example.com/v1",
"user@api.example.com",
"*.example.com",
"api.exam ple.com",
"api.exämple.com",
&format!("{}.example.com", "a".repeat(64)),
&format!("{}.com", "a.".repeat(200)),
] {
assert!(!is_grantable_host(host), "{host} was accepted");
}
}
#[test]
fn a_grantable_identifier_can_never_close_an_sql_identifier() {
for ident in ["orders", "o", "line_items", "t1"] {
assert!(is_grantable_ident(ident), "{ident}");
}
for ident in [
"",
"Orders",
"1orders",
"_orders",
"orders-2",
"orders ",
"orders;",
"\"orders\"",
"orders'",
"orders`",
"public.users",
"orders--",
&"o".repeat(MAX_GRANT_IDENT_LEN + 1),
] {
assert!(!is_grantable_ident(ident), "{ident} was accepted");
}
}
#[test]
fn a_grantable_name_allows_the_hyphen_an_author_would_write() {
for name in ["order-summary", "send-receipt", "reindex"] {
assert!(is_grantable_name(name), "{name}");
assert_eq!(is_grantable_ident(name), !name.contains('-'), "{name}");
}
for name in ["-lead", "Order-Summary", "order summary", ""] {
assert!(!is_grantable_name(name), "{name} was accepted");
}
}
#[test]
fn a_grant_is_matched_by_equality_and_never_by_anything_looser() {
let grants = CapabilityGrants {
hosts: vec!["api.example.com".to_owned()],
tables: vec!["orders".to_owned()],
..CapabilityGrants::default()
};
assert!(grants.allows(SandboxCapability::HttpOutbound, "api.example.com"));
for near_miss in [
"api.example.com.attacker.test",
"evil-api.example.com",
"api.example.co",
"API.example.com",
"api.example.com ",
"",
] {
assert!(
!grants.allows(SandboxCapability::HttpOutbound, near_miss),
"{near_miss} matched"
);
}
assert!(!grants.allows(SandboxCapability::Db, "orders_secret"));
assert!(!grants.allows(SandboxCapability::Kv, "anything"));
assert!(grants.list_for(SandboxCapability::Kv).is_none());
}
#[test]
fn a_delta_summary_is_empty_when_nothing_grew() {
let delta = ConsentDelta::default();
assert!(!delta.requires_consent());
assert!(delta.summary().is_empty());
assert!(delta.to_string().is_empty());
}
#[test]
fn every_quota_field_is_checked_by_validate() {
let defaults = CapabilityQuotas::default();
assert!(defaults.validate().is_ok());
for (index, (field, _)) in defaults.fields().into_iter().enumerate() {
let mut broken = defaults;
match index {
0 => broken.kv_reads = 0,
1 => broken.kv_writes = 0,
2 => broken.kv_value_bytes = 0,
3 => broken.outbound_calls = 0,
4 => broken.outbound_response_bytes = 0,
5 => broken.outbound_request_bytes = 0,
6 => broken.db_reads = 0,
7 => broken.db_writes = 0,
8 => broken.db_rows = 0,
9 => broken.job_enqueues = 0,
10 => broken.render_bytes = 0,
11 => broken.calls = 0,
12 => broken.outbound_timeout_ms = 0,
13 => broken.calls_per_second = 0,
other => panic!("quota field {other} ({field}) has no case here"),
}
assert_eq!(
broken.validate(),
Err(ManifestError::QuotaOutOfRange {
field,
value: 0,
max: MAX_QUOTA
}),
"{field}"
);
}
}
#[test]
fn a_grant_list_longer_than_the_ceiling_is_refused_before_it_is_scanned() {
let grants = CapabilityGrants {
hosts: (0..=MAX_GRANT_ENTRIES)
.map(|index| format!("h{index}.example.com"))
.collect(),
..CapabilityGrants::default()
};
assert_eq!(
grants.validate(&[SandboxCapability::HttpOutbound]),
Err(ManifestError::TooManyGrantEntries {
field: "hosts",
found: MAX_GRANT_ENTRIES + 1,
max: MAX_GRANT_ENTRIES,
})
);
}
#[test]
fn no_spelling_of_an_address_is_grantable_as_a_host() {
for spelling in [
"127.0.0.1",
"127.1",
"127.0.1",
"0177.0.0.1",
"0x7f.1",
"169.254.169.254",
"10.0.0.1",
"1.2.3.4",
] {
assert!(
!is_grantable_host(spelling),
"{spelling} is an address, not a name"
);
}
for name in [
"api.example.com",
"1.example.com",
"10.internal.example",
"a-b.example.co.uk",
] {
assert!(is_grantable_host(name), "{name} is a name");
}
}
}