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:
@@ -886,4 +886,64 @@ mod tests {
|
|||||||
s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap();
|
s.update(&cx, "users", id, json!({ "x": 2 })).await.unwrap();
|
||||||
let _ = s.delete(&cx, "users", id).await.unwrap();
|
let _ = s.delete(&cx, "users", id).await.unwrap();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PICLOUD_DOCS_MAX_VALUE_BYTES` — the docs analogue of the KV anti-DoS rail.
|
||||||
|
/// Pins that an oversized document is rejected BEFORE authz, and covers create
|
||||||
|
/// AND update (both encode-and-check). The cx is an authenticated member with
|
||||||
|
/// no role (authz-denied) so the ordering is observable: size-first returns
|
||||||
|
/// `ValueTooLarge`, authz-first would return `Forbidden`. An anon cx couldn't
|
||||||
|
/// tell the two apart.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_document_is_rejected_before_authz() {
|
||||||
|
let s = DocsServiceImpl::with_max_value_bytes(
|
||||||
|
Arc::new(InMemoryDocsRepo::default()),
|
||||||
|
Arc::new(DenyingAuthzRepo),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
let cx = member_no_role_cx(AppId::new());
|
||||||
|
|
||||||
|
let err = s
|
||||||
|
.create(&cx, "users", json!({ "blob": "x".repeat(100) }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, DocsError::ValueTooLarge { limit: 16, .. }),
|
||||||
|
"an oversized create must be ValueTooLarge before authz; got {err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control: an under-cap create with the same cx is Forbidden — the cx is
|
||||||
|
// genuinely authz-denied, so the case above bypassed authz. And it seeds a
|
||||||
|
// doc via an ALLOWING service so we can then test the update path.
|
||||||
|
assert!(
|
||||||
|
matches!(
|
||||||
|
s.create(&cx, "users", json!({ "x": 1 })).await.unwrap_err(),
|
||||||
|
DocsError::Forbidden
|
||||||
|
),
|
||||||
|
"the control confirms this cx is authz-denied"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update also encodes-and-checks before authz — an update that skipped the
|
||||||
|
// cap would be a free bypass. Seed with an allowing service, then update
|
||||||
|
// through the denied one.
|
||||||
|
let seeder = DocsServiceImpl::with_max_value_bytes(
|
||||||
|
Arc::new(InMemoryDocsRepo::default()),
|
||||||
|
Arc::new(AllowingAuthzRepo),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
let owner = owner_cx(AppId::new());
|
||||||
|
let id = seeder
|
||||||
|
.create(&owner, "users", json!({ "x": 1 }))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let err = seeder
|
||||||
|
.update(&owner, "users", id, json!({ "blob": "x".repeat(100) }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, DocsError::ValueTooLarge { limit: 16, .. }),
|
||||||
|
"an oversized update must be ValueTooLarge too; got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -561,6 +561,48 @@ mod tests {
|
|||||||
// No panic, no Forbidden.
|
// No panic, no Forbidden.
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PICLOUD_KV_MAX_VALUE_BYTES` is called out in CLAUDE.md as an anti-DoS rail:
|
||||||
|
/// oversized values are rejected "before authz so anonymous public scripts
|
||||||
|
/// can't DoS Postgres JSONB columns." Nothing asserted it. This does, and it
|
||||||
|
/// pins the ORDERING — the security-relevant part.
|
||||||
|
///
|
||||||
|
/// The cx is an AUTHENTICATED member with no role, which `DenyingAuthzRepo`
|
||||||
|
/// rejects (see `authed_cx_with_no_role_is_forbidden`). That is what makes the
|
||||||
|
/// ordering observable: an anon cx passes `script_gate` regardless, so it
|
||||||
|
/// could not tell the two orders apart. With a denied cx, a size-check-first
|
||||||
|
/// service returns `ValueTooLarge` and an authz-first one returns `Forbidden`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_value_is_rejected_before_authz() {
|
||||||
|
let kv = KvServiceImpl::with_max_value_bytes(
|
||||||
|
Arc::new(InMemoryKvRepo::default()),
|
||||||
|
Arc::new(DenyingAuthzRepo),
|
||||||
|
Arc::new(NoopEventEmitter),
|
||||||
|
16,
|
||||||
|
);
|
||||||
|
let cx = member_no_role_cx(AppId::new());
|
||||||
|
|
||||||
|
let err = kv
|
||||||
|
.set(&cx, "widgets", "k", serde_json::json!("x".repeat(100)))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, KvError::ValueTooLarge { limit: 16, .. }),
|
||||||
|
"an oversized value must be rejected as ValueTooLarge BEFORE authz; an \
|
||||||
|
authz-first order would return Forbidden for this denied cx. got {err:?}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control: an under-cap value with the SAME denied cx returns Forbidden —
|
||||||
|
// proving the cx really is denied, so the case above genuinely bypassed authz.
|
||||||
|
let err = kv
|
||||||
|
.set(&cx, "widgets", "k", serde_json::json!("x"))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, KvError::Forbidden),
|
||||||
|
"the control confirms this cx is authz-denied; got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Authenticated principal with no role on the app: the
|
/// Authenticated principal with no role on the app: the
|
||||||
/// `DenyingAuthzRepo` returns no membership, so the capability
|
/// `DenyingAuthzRepo` returns no membership, so the capability
|
||||||
/// check denies. Set must surface KvError::Forbidden.
|
/// check denies. Set must surface KvError::Forbidden.
|
||||||
|
|||||||
@@ -775,4 +775,47 @@ mod tests {
|
|||||||
other => panic!("expected SubscriberToken, got {other:?}"),
|
other => panic!("expected SubscriberToken, got {other:?}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `PICLOUD_PUBSUB_MAX_MESSAGE_BYTES` — CLAUDE.md: "Prevents one publish from
|
||||||
|
/// amplifying into N outbox rows × M MB." Pins that an oversized message is
|
||||||
|
/// rejected BEFORE authz. The cx is an authenticated member that
|
||||||
|
/// `DenyingAuthzRepo` rejects, so the ordering is observable: size-first
|
||||||
|
/// returns `MessageTooLarge`, authz-first would return `Forbidden`.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn oversized_message_is_rejected_before_authz() {
|
||||||
|
let repo = Arc::new(InMemoryPubsubRepo::new(vec![]));
|
||||||
|
let svc = svc(repo.clone(), Arc::new(DenyingAuthzRepo)).with_max_message_bytes(16);
|
||||||
|
let cx = member_cx(AppId::new());
|
||||||
|
|
||||||
|
let err = svc
|
||||||
|
.publish_durable(
|
||||||
|
&cx,
|
||||||
|
"events",
|
||||||
|
serde_json::json!({ "blob": "x".repeat(100) }),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, PubsubError::MessageTooLarge { limit: 16, .. }),
|
||||||
|
"an oversized publish must be MessageTooLarge before authz; an authz-first \
|
||||||
|
order would return Forbidden for this denied cx. got {err:?}"
|
||||||
|
);
|
||||||
|
// The oversized publish never reached the repo — no outbox amplification.
|
||||||
|
assert_eq!(
|
||||||
|
repo.written_count(),
|
||||||
|
0,
|
||||||
|
"a rejected publish must not write any fan-out rows"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Control: an under-cap publish with the same cx is Forbidden — confirming
|
||||||
|
// the cx is authz-denied, so the case above genuinely bypassed authz.
|
||||||
|
let err = svc
|
||||||
|
.publish_durable(&cx, "events", serde_json::json!({ "ok": 1 }))
|
||||||
|
.await
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(
|
||||||
|
matches!(err, PubsubError::Forbidden),
|
||||||
|
"the control confirms this cx is authz-denied; got {err:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
#[tokio::test]
|
||||||
async fn empty_queue_name_rejects() {
|
async fn empty_queue_name_rejects() {
|
||||||
let repo = Arc::new(CapturingRepo {
|
let repo = Arc::new(CapturingRepo {
|
||||||
@@ -366,11 +390,15 @@ mod tests {
|
|||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn oversized_payload_is_rejected_before_authz() {
|
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 {
|
let repo = Arc::new(CapturingRepo {
|
||||||
last: tokio::sync::Mutex::new(None),
|
last: tokio::sync::Mutex::new(None),
|
||||||
});
|
});
|
||||||
let svc = QueueServiceImpl::with_max_payload_bytes(repo, Arc::new(AlwaysAllowAuthz), 16);
|
let svc = QueueServiceImpl::with_max_payload_bytes(repo, Arc::new(DenyAllAuthz), 16);
|
||||||
let cx = anon_cx();
|
let cx = member_cx();
|
||||||
let payload = serde_json::json!({ "blob": "x".repeat(200) });
|
let payload = serde_json::json!({ "blob": "x".repeat(200) });
|
||||||
let err = svc
|
let err = svc
|
||||||
.enqueue(&cx, "jobs", payload, EnqueueOpts::default())
|
.enqueue(&cx, "jobs", payload, EnqueueOpts::default())
|
||||||
@@ -378,7 +406,23 @@ mod tests {
|
|||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(
|
assert!(
|
||||||
matches!(err, QueueError::PayloadTooLarge { .. }),
|
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]
|
#[tokio::test]
|
||||||
async fn authed_principal_denied_returns_forbidden_variant() {
|
async fn authed_principal_denied_returns_forbidden_variant() {
|
||||||
use picloud_shared::{InstanceRole, Principal, UserId};
|
|
||||||
let repo = Arc::new(CapturingRepo {
|
let repo = Arc::new(CapturingRepo {
|
||||||
last: tokio::sync::Mutex::new(None),
|
last: tokio::sync::Mutex::new(None),
|
||||||
});
|
});
|
||||||
let svc = QueueServiceImpl::new(repo, Arc::new(DenyAllAuthz));
|
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
|
let err = svc
|
||||||
.enqueue(&cx, "x", serde_json::Value::Null, EnqueueOpts::default())
|
.enqueue(
|
||||||
|
&member_cx(),
|
||||||
|
"x",
|
||||||
|
serde_json::Value::Null,
|
||||||
|
EnqueueOpts::default(),
|
||||||
|
)
|
||||||
.await
|
.await
|
||||||
.unwrap_err();
|
.unwrap_err();
|
||||||
assert!(matches!(err, QueueError::Forbidden));
|
assert!(matches!(err, QueueError::Forbidden));
|
||||||
|
|||||||
Reference in New Issue
Block a user