test(services): pin that the per-value byte caps reject before authz
CLAUDE.md calls the KV/docs/pubsub/queue value-size caps an anti-DoS rail: oversized payloads are rejected "before authz so anonymous public scripts can't DoS Postgres." Only queue had a test, and it used an allow-all authz + anon cx — which catches a DROPPED cap but not a REORDERED one, because an anon cx passes script_gate regardless. Each service now has an ordering-proof test: a DENYING authz repo + an AUTHENTICATED member cx, so a size-check-first service returns *TooLarge while an authz-first one would return Forbidden. Each pairs it with an under-cap control through the same denied cx (returns Forbidden) to prove the cx really is denied, so the *TooLarge case genuinely bypassed authz. Queue's pre-existing test is upgraded to the same shape (+ a shared member_cx helper). Mutation-verified: moving the KV size check after authz flips its result to Forbidden and the test fails. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -275,6 +275,30 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// An AUTHENTICATED member with no app role. `DenyAllAuthz` denies it — unlike
|
||||
/// an anon cx, which passes `script_gate` regardless, so only an authenticated-
|
||||
/// denied cx can distinguish "size check ran before authz" from "after".
|
||||
fn member_cx() -> SdkCallCx {
|
||||
use picloud_shared::{InstanceRole, Principal, UserId};
|
||||
let exec = ExecutionId::new();
|
||||
SdkCallCx {
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
principal: Some(Principal {
|
||||
user_id: UserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
execution_id: exec,
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: exec,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn empty_queue_name_rejects() {
|
||||
let repo = Arc::new(CapturingRepo {
|
||||
@@ -366,11 +390,15 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn oversized_payload_is_rejected_before_authz() {
|
||||
// DenyAllAuthz + an authenticated cx makes the ORDERING observable: a
|
||||
// size-first service returns PayloadTooLarge, an authz-first one would
|
||||
// return Forbidden. (The previous version used AlwaysAllow + anon, which
|
||||
// caught a DROPPED cap but not a REORDERED one.)
|
||||
let repo = Arc::new(CapturingRepo {
|
||||
last: tokio::sync::Mutex::new(None),
|
||||
});
|
||||
let svc = QueueServiceImpl::with_max_payload_bytes(repo, Arc::new(AlwaysAllowAuthz), 16);
|
||||
let cx = anon_cx();
|
||||
let svc = QueueServiceImpl::with_max_payload_bytes(repo, Arc::new(DenyAllAuthz), 16);
|
||||
let cx = member_cx();
|
||||
let payload = serde_json::json!({ "blob": "x".repeat(200) });
|
||||
let err = svc
|
||||
.enqueue(&cx, "jobs", payload, EnqueueOpts::default())
|
||||
@@ -378,7 +406,23 @@ mod tests {
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, QueueError::PayloadTooLarge { .. }),
|
||||
"expected PayloadTooLarge, got {err:?}"
|
||||
"an oversized payload must be PayloadTooLarge before authz; an authz-first \
|
||||
order would return Forbidden for this denied cx. got {err:?}"
|
||||
);
|
||||
// Control: an under-cap payload with the same cx is Forbidden — confirming
|
||||
// the cx is authz-denied, so the case above genuinely bypassed authz.
|
||||
let err = svc
|
||||
.enqueue(
|
||||
&cx,
|
||||
"jobs",
|
||||
serde_json::json!({ "ok": 1 }),
|
||||
EnqueueOpts::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(err, QueueError::Forbidden),
|
||||
"the control confirms this cx is authz-denied; got {err:?}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -396,30 +440,17 @@ mod tests {
|
||||
|
||||
#[tokio::test]
|
||||
async fn authed_principal_denied_returns_forbidden_variant() {
|
||||
use picloud_shared::{InstanceRole, Principal, UserId};
|
||||
let repo = Arc::new(CapturingRepo {
|
||||
last: tokio::sync::Mutex::new(None),
|
||||
});
|
||||
let svc = QueueServiceImpl::new(repo, Arc::new(DenyAllAuthz));
|
||||
let exec = ExecutionId::new();
|
||||
let cx = SdkCallCx {
|
||||
app_id: AppId::new(),
|
||||
script_id: ScriptId::new(),
|
||||
principal: Some(Principal {
|
||||
user_id: UserId::new(),
|
||||
instance_role: InstanceRole::Member,
|
||||
scopes: None,
|
||||
app_binding: None,
|
||||
}),
|
||||
execution_id: exec,
|
||||
request_id: RequestId::new(),
|
||||
trigger_depth: 0,
|
||||
root_execution_id: exec,
|
||||
is_dead_letter_handler: false,
|
||||
event: None,
|
||||
};
|
||||
let err = svc
|
||||
.enqueue(&cx, "x", serde_json::Value::Null, EnqueueOpts::default())
|
||||
.enqueue(
|
||||
&member_cx(),
|
||||
"x",
|
||||
serde_json::Value::Null,
|
||||
EnqueueOpts::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(matches!(err, QueueError::Forbidden));
|
||||
|
||||
Reference in New Issue
Block a user