fix(security): server-authoritative approval gate + auth/session/file hardening
Remediate the HIGH and security-relevant findings from the 2026-07-11 audit. H1 — the per-env approval gate is now server-authoritative. The governing project is resolved from the target node's nearest-claimed ancestor (`governing_env_policy`/`_tree` + `governing_project_id` + `ProjectRepository::get_environments_by_id`), independent of the client-supplied `[project]`. Omitting or spoofing the project block can no longer skip a gate the owning project established; a to-create group resolves its declared parent's chain so a fresh subtree node inherits the gate. Fails closed on any read error. H2 — the API-key prefix slice (`&rest[..8]`) is now the boundary-safe `rest.get(..8)`, so an attacker-supplied multibyte bearer can't panic the request task (unauthenticated per-request DoS). Regression test added. C1 — admin sessions gain an absolute lifetime cap (migration 0070, `PICLOUD_SESSION_ABSOLUTE_TTL_HOURS`, default 30d): `lookup` filters it, `touch` clamps the sliding bump to it, so a continuously-used or stolen-but-warm token self-expires. Mirrors the data-plane app-user cap. C2 — `Cache-Control: no-store` on the login and API-key-mint responses (the two that return a raw credential), so a proxy/CDN/browser cache can't retain it. B8 — file downloads are header-safe: `sanitize_stored_filename` guarantees a valid `HeaderValue` (no panic on a control-char name) and BOTH the per-app and group download paths now set attachment + `X-Content-Type-Options: nosniff` + a restrictive CSP, closing a group-path stored-XSS gap. Also folds in the server-side plan-warning plumbing (`plan_warnings`, `PlanResult::warnings`) and the `app_only_reject` message helper that the CLI plan-preview change builds on, plus operator security notes (reads-open shared- topic SSE; the `--env` label is advisory, not a boundary). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -485,6 +485,13 @@ pub struct PlanResult {
|
||||
/// `plan` time, before an `apply` refuses.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub approvals_required: Vec<String>,
|
||||
/// The desired-state warnings `apply` would emit, computed at plan so `pic
|
||||
/// plan` is a faithful preview for CI: an enabled binding on a disabled
|
||||
/// script (deployed-but-unreachable), an endpoint with no route/trigger, a
|
||||
/// `[suppress]` that matches no inherited template. Post-commit side-effect
|
||||
/// warnings (route-table refresh, materialization skips) are apply-only.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------------------
|
||||
@@ -695,6 +702,10 @@ pub struct NodePlan {
|
||||
/// §6 M2: for a GROUP node, its structural state (in-sync / diverged).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub structure: Option<StructurePreview>,
|
||||
/// The desired-state warnings this node's apply would emit (same set as
|
||||
/// [`PlanResult::warnings`]), so `pic plan --dir` previews them per node.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// What `plan_tree` returns: a per-node diff plus ONE combined bound-plan token
|
||||
@@ -895,47 +906,17 @@ impl ApplyService {
|
||||
.await
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an
|
||||
/// apply/plan — `env_name -> confirm`, computed as `persisted ∪ declared`
|
||||
/// (an env is confirm-required if EITHER the stored policy OR the request's
|
||||
/// `[project.environments]` marks it so). Loading the persisted side is what
|
||||
/// makes the gate hermetic: a request that omits (or flips-to-false) a policy
|
||||
/// a prior apply persisted still trips the gate. Empty when no project is
|
||||
/// declared. A persisted-read failure surfaces as `Backend` (fail-closed —
|
||||
/// we never silently drop a gate).
|
||||
pub async fn effective_env_policy(
|
||||
&self,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let Some(project) = project else {
|
||||
return Ok(std::collections::BTreeMap::new());
|
||||
};
|
||||
let mut effective = self
|
||||
.projects
|
||||
.get_environments_by_slug(&project.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
for (env, pol) in &project.environments {
|
||||
// Union on confirm: a declared gate strengthens, never weakens.
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
/// The sorted names of every effectively-gated env (`effective_env_policy`
|
||||
/// filtered to `confirm`), surfaced as `plan.approvals_required`.
|
||||
async fn effective_gated_envs(
|
||||
&self,
|
||||
project: Option<&ProjectDecl>,
|
||||
) -> Result<Vec<String>, ApplyError> {
|
||||
let policy = self.effective_env_policy(project).await?;
|
||||
/// The sorted names of every gated env in a computed policy (`confirm`
|
||||
/// entries), surfaced as `plan.approvals_required`. Callers pass the
|
||||
/// governing policy so plan's preview matches what apply gates on.
|
||||
fn gated_envs_from(policy: &std::collections::BTreeMap<String, bool>) -> Vec<String> {
|
||||
let mut gated: Vec<String> = policy
|
||||
.into_iter()
|
||||
.filter_map(|(env, confirm)| confirm.then_some(env))
|
||||
.iter()
|
||||
.filter(|&(_, confirm)| *confirm)
|
||||
.map(|(env, _)| env.clone())
|
||||
.collect();
|
||||
gated.sort_unstable();
|
||||
Ok(gated)
|
||||
gated
|
||||
}
|
||||
|
||||
/// Owner-generic plan (Phase 5): diff `bundle` against an app OR group node.
|
||||
@@ -979,7 +960,10 @@ impl ApplyService {
|
||||
// `apply` can refuse if the node changed underneath it (§4.2).
|
||||
state_token: state_token_with_names(¤t, ¤t_names),
|
||||
ownership,
|
||||
approvals_required: self.effective_gated_envs(project).await?,
|
||||
approvals_required: Self::gated_envs_from(
|
||||
&self.governing_env_policy(owner, project).await?,
|
||||
),
|
||||
warnings: self.plan_warnings(owner, bundle).await?,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1100,10 +1084,9 @@ impl ApplyService {
|
||||
// it the reconcile would insert an inert `app_id`-owned marker row that
|
||||
// no resolver ever reads.
|
||||
if !is_group && !bundle.collections.is_empty() {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app manifest cannot declare shared collections — \
|
||||
they are owned by a group"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"manifest cannot declare shared collections",
|
||||
"they are owned by a group",
|
||||
));
|
||||
}
|
||||
// §11 tail: `sealed` is a GROUP-template property — it makes an
|
||||
@@ -1114,26 +1097,26 @@ impl ApplyService {
|
||||
// node inherits down).
|
||||
if !is_group {
|
||||
if bundle.routes.iter().any(|r| r.sealed) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app route cannot be `sealed` — sealing marks a group \
|
||||
template as non-suppressible; an app route is never inherited"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"route cannot be `sealed`",
|
||||
"sealing marks a group template as non-suppressible; an app \
|
||||
route is never inherited",
|
||||
));
|
||||
}
|
||||
if bundle.triggers.iter().any(BundleTrigger::sealed) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app trigger cannot be `sealed` — sealing marks a group \
|
||||
template as non-suppressible; an app trigger is never inherited"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"trigger cannot be `sealed`",
|
||||
"sealing marks a group template as non-suppressible; an app \
|
||||
trigger is never inherited",
|
||||
));
|
||||
}
|
||||
// §11.6: `shared` watches a group's SHARED collection — meaningless
|
||||
// for an app (apps don't own shared collections).
|
||||
if bundle.triggers.iter().any(BundleTrigger::shared) {
|
||||
return Err(ApplyError::Invalid(
|
||||
"an app trigger cannot be `shared` — a shared-collection \
|
||||
trigger is owned by the group that declares the collection"
|
||||
.into(),
|
||||
return Err(app_only_reject(
|
||||
"trigger cannot be `shared`",
|
||||
"a shared-collection trigger is owned by the group that \
|
||||
declares the collection",
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -1710,24 +1693,11 @@ impl ApplyService {
|
||||
validate_email_secrets_present(bundle, ¤t.secret_names)?;
|
||||
let plan = compute_diff_with_names(¤t, bundle, ¤t_names);
|
||||
let mut report = ApplyReport::default();
|
||||
// §4.7 warning: an enabled binding pointing at a disabled script is
|
||||
// deployed-but-unreachable. Not an error (it's valid desired state),
|
||||
// but surfaced so the operator isn't surprised by a silent 404.
|
||||
report.warnings.extend(disabled_target_warnings(bundle));
|
||||
// A group endpoint is reached by *inheritance* (a descendant app binds
|
||||
// it by name), never by its own route, so the "no route/trigger"
|
||||
// warning is noise for every group script — emit it for apps only.
|
||||
if let ApplyOwner::App(_) = owner {
|
||||
report
|
||||
.warnings
|
||||
.extend(unreachable_endpoint_warnings(bundle));
|
||||
}
|
||||
// §11 tail: a suppress reference that matches no inherited template (or
|
||||
// only sealed ones) silently does nothing — warn (typo guard), don't
|
||||
// fail. M1: runs for a group node too (it walks the group's own chain).
|
||||
// The desired-state warnings — identical to what `pic plan` previews, so
|
||||
// plan and apply agree (see `plan_warnings`).
|
||||
report
|
||||
.warnings
|
||||
.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
||||
.extend(self.plan_warnings(owner, bundle).await?);
|
||||
|
||||
self.reconcile_node_tx(
|
||||
&mut tx,
|
||||
@@ -1800,16 +1770,20 @@ impl ApplyService {
|
||||
// §6 M2: for an existing group node, preview a structural divergence
|
||||
// (server parent ≠ manifest parent) before apply.
|
||||
let structure = self.divergence_preview(p.node, &existing).await?;
|
||||
let warnings = self.plan_warnings(p.owner, &p.node.bundle).await?;
|
||||
nodes.push(NodePlan {
|
||||
kind: p.node.kind,
|
||||
slug: p.node.slug.clone(),
|
||||
plan: p.plan,
|
||||
ownership,
|
||||
structure,
|
||||
warnings,
|
||||
});
|
||||
}
|
||||
// §6: a to-create group is previewed with a full-create plan; if the
|
||||
// repo declares a `[project]`, the create will also CLAIM it.
|
||||
// repo declares a `[project]`, the create will also CLAIM it. It has no
|
||||
// node id yet (no chain to walk), so only the pure desired-state warnings
|
||||
// apply — a disabled binding; unreachable/suppress need an existing owner.
|
||||
for c in to_create {
|
||||
let ownership = declared.map(|d| preview_ownership(true, None, Some(d)));
|
||||
nodes.push(NodePlan {
|
||||
@@ -1818,12 +1792,15 @@ impl ApplyService {
|
||||
plan: c.plan,
|
||||
ownership,
|
||||
structure: None,
|
||||
warnings: disabled_target_warnings(&c.node.bundle),
|
||||
});
|
||||
}
|
||||
Ok(TreePlanResult {
|
||||
nodes,
|
||||
state_token: token,
|
||||
approvals_required: self.effective_gated_envs(project).await?,
|
||||
approvals_required: Self::gated_envs_from(
|
||||
&self.governing_env_policy_tree(bundle, project).await?,
|
||||
),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2735,6 +2712,147 @@ impl ApplyService {
|
||||
Ok(Some((pid, slug)))
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate, server-authoritative): the id of the project that
|
||||
/// GOVERNS `owner` — its nearest-claimed ancestor (inclusive: a group node
|
||||
/// uses its own claim first, then walks up; an app node walks its group
|
||||
/// chain). This is the same ownership walk `ownership_preview` uses, and it
|
||||
/// is deliberately independent of any client-supplied `[project]` — the
|
||||
/// server decides which project's approval policy applies.
|
||||
async fn governing_project_id(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
) -> Result<Option<ProjectId>, ApplyError> {
|
||||
let anchor = match owner {
|
||||
ApplyOwner::Group(g) => g,
|
||||
ApplyOwner::App(a) => {
|
||||
let Some(app) = self
|
||||
.apps
|
||||
.get_by_id(a)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
app.group_id
|
||||
}
|
||||
};
|
||||
let chain = self
|
||||
.groups
|
||||
.ancestors(anchor)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?;
|
||||
Ok(chain.iter().find_map(|g| g.owner_project))
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate): the EFFECTIVE per-env approval policy for an apply
|
||||
/// to `owner` — `env -> confirm` — computed as `governing ∪ declared`. The
|
||||
/// GOVERNING side is loaded from the project the server RESOLVED as owning
|
||||
/// the target node (`governing_project_id`), NOT from the client's declared
|
||||
/// slug — so a request that omits or spoofs `[project]` still trips a gate
|
||||
/// the owning project established. The declared side only STRENGTHENS (union
|
||||
/// on confirm), never weakens. A persisted-read error surfaces as `Backend`
|
||||
/// (fail-closed — a gate is never silently dropped).
|
||||
pub async fn governing_env_policy(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
declared: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let mut effective = match self.governing_project_id(owner).await? {
|
||||
Some(pid) => self
|
||||
.projects
|
||||
.get_environments_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?,
|
||||
None => std::collections::BTreeMap::new(),
|
||||
};
|
||||
if let Some(decl) = declared {
|
||||
for (env, pol) in &decl.environments {
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
/// §3 M3 (hermetic gate) for a TREE apply: the union of every declared
|
||||
/// node's governing policy with the declared side. Each existing node is
|
||||
/// resolved to its owning project server-side; a to-create group node has
|
||||
/// nothing persisted to gate (its claim is established by this same apply)
|
||||
/// so it contributes only the declared policy. Un-bypassable for the real
|
||||
/// case — applying to existing nodes under a claimed, gated subtree.
|
||||
pub async fn governing_env_policy_tree(
|
||||
&self,
|
||||
bundle: &TreeBundle,
|
||||
declared: Option<&ProjectDecl>,
|
||||
) -> Result<std::collections::BTreeMap<String, bool>, ApplyError> {
|
||||
let mut effective: std::collections::BTreeMap<String, bool> =
|
||||
std::collections::BTreeMap::new();
|
||||
for node in &bundle.nodes {
|
||||
// The project that governs this node. An existing node uses its own
|
||||
// ownership walk. A to-create GROUP has no id yet, so resolve the
|
||||
// governing project from its DECLARED parent's chain — a gated,
|
||||
// claimed pre-existing ancestor still governs a node created beneath
|
||||
// it, closing the whole-block-omission gap for a fresh subtree node.
|
||||
let governing_pid = match node.kind {
|
||||
NodeKind::App => {
|
||||
// Apps are never to-create in the tree path (resolve errors),
|
||||
// so a miss here means the app truly doesn't exist yet.
|
||||
match crate::app_repo::resolve_app(self.apps.as_ref(), &node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
Some(l) => self.governing_project_id(ApplyOwner::App(l.app.id)).await?,
|
||||
None => continue,
|
||||
}
|
||||
}
|
||||
NodeKind::Group => match self
|
||||
.groups
|
||||
.get_by_slug(&node.slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
Some(g) => self.governing_project_id(ApplyOwner::Group(g.id)).await?,
|
||||
None => match node.parent.as_deref() {
|
||||
Some(parent_slug) => match self
|
||||
.groups
|
||||
.get_by_slug(parent_slug)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
Some(parent) => {
|
||||
self.governing_project_id(ApplyOwner::Group(parent.id))
|
||||
.await?
|
||||
}
|
||||
// Parent is itself to-create (no persisted claim yet,
|
||||
// so no persisted gate from it) or absent → nothing to
|
||||
// govern this node with.
|
||||
None => continue,
|
||||
},
|
||||
None => continue,
|
||||
},
|
||||
},
|
||||
};
|
||||
if let Some(pid) = governing_pid {
|
||||
for (env, confirm) in self
|
||||
.projects
|
||||
.get_environments_by_id(pid)
|
||||
.await
|
||||
.map_err(|e| ApplyError::Backend(e.to_string()))?
|
||||
{
|
||||
let entry = effective.entry(env).or_insert(false);
|
||||
*entry = *entry || confirm;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(decl) = declared {
|
||||
for (env, pol) in &decl.environments {
|
||||
let entry = effective.entry(env.clone()).or_insert(false);
|
||||
*entry = *entry || pol.confirm;
|
||||
}
|
||||
}
|
||||
Ok(effective)
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------
|
||||
// §6/§7 attach point — a project's ceiling. Every applied node must live
|
||||
// strictly within the subtree rooted at the declared `parent_group`.
|
||||
@@ -3389,6 +3507,27 @@ impl ApplyService {
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// The desired-state warnings a plan/apply of `bundle` onto `owner` would
|
||||
/// emit — the ones knowable BEFORE committing, so `pic plan` previews exactly
|
||||
/// what `apply` reports (post-commit side-effect warnings like route-table
|
||||
/// refresh or materialization skips are apply-only). Covers: an enabled
|
||||
/// binding on a disabled script (deployed-but-unreachable), an endpoint with
|
||||
/// no route/trigger (apps only — a group endpoint is reached by inheritance),
|
||||
/// and a `[suppress]` that matches no inherited template.
|
||||
async fn plan_warnings(
|
||||
&self,
|
||||
owner: ApplyOwner,
|
||||
bundle: &Bundle,
|
||||
) -> Result<Vec<String>, ApplyError> {
|
||||
let mut warnings = Vec::new();
|
||||
warnings.extend(disabled_target_warnings(bundle));
|
||||
if let ApplyOwner::App(_) = owner {
|
||||
warnings.extend(unreachable_endpoint_warnings(bundle));
|
||||
}
|
||||
warnings.extend(self.dangling_suppress_warnings(owner, bundle).await?);
|
||||
Ok(warnings)
|
||||
}
|
||||
|
||||
/// §11 tail typo guard: a suppress reference that matches **no** inherited
|
||||
/// template on the app's chain does nothing — return a warning for each such
|
||||
/// dangling reference (soft; never fails the apply). Trigger refs match an
|
||||
@@ -4843,6 +4982,14 @@ fn unreachable_endpoint_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
/// script the manifest marks *disabled* is deployed but unreachable (the
|
||||
/// route 404s, the trigger won't fire). Valid desired state, so a warning —
|
||||
/// not an error — keyed on the bundle alone.
|
||||
/// Consistent phrasing for a GROUP-only feature declared on an APP node — the
|
||||
/// several such guards in `validate_bundle_for` (§11.6 shared collections, §11
|
||||
/// tail `sealed`, shared triggers). Renders `an app <what> — <why>` so the
|
||||
/// app/group boundary reads the same everywhere.
|
||||
fn app_only_reject(what: &str, why: &str) -> ApplyError {
|
||||
ApplyError::Invalid(format!("an app {what} — {why}"))
|
||||
}
|
||||
|
||||
fn disabled_target_warnings(bundle: &Bundle) -> Vec<String> {
|
||||
let disabled: HashSet<&str> = bundle
|
||||
.scripts
|
||||
|
||||
Reference in New Issue
Block a user