crates/monetize-site-buy/tests/site_buy_security.rs
//! **The buy doors' rules against a hostile browser, one place** (P5-SEC,
//! `security.rule-tests`).
//!
//! The relay sits on a product site, in front of the ONE box: whatever it lets through is
//! asked of the shop with the site's own bearer. Each scenario runs for both sites (Gunnar's
//! cookie, Holger's bearer) and names one rule; each was seen to fail against a deliberately
//! broken check before it passed (the breaks are in /work/handback/p5-sec.md):
//!
//! | rule | the check | what the browser gets |
//! |---|---|---|
//! | nobody signed in (a visitor, a machine) buys, cancels or reads an account's orders | `person`, `account_ticket` | 401, no ticket minted, no checkout |
//! | the tenant is the session's, never the body's (wrong tenant) | `buy_bundle` and the private start read the `Buyer` | the order is booked for the signed-in account; another account's order reads 404 |
//! | a forged session id never reaches the shop; a ticket the shop refuses is 403, never an outage | `rules::session`; `rules::http_status_of` | 400 [`NOT_A_SESSION`]; 403 |
//! | a request replayed past the door's budget is refused before the shop | `BuySite::allowed` | 429, the shop not asked |
//! | a body over [`BUY_BODY_MAX`] is refused before it is parsed (oversized) | the router's body limit | 413 |
//!
//! The ticket's signature and nonce are the ONE box's to judge (monetize-server's
//! door_security.rs); the relay passes the refusal on, and that mapping is held here.
mod sites;
use std::sync::Arc;
use axum::http::StatusCode;
use monetize_site_buy::relay::NOT_A_SESSION;
use monetize_site_buy::testkit::{What, BUNDLE};
use monetize_site_buy::{Relay, BUY_BODY_MAX};
use serde_json::json;
use sites::{Kit, Shape, TestSite};
fn sizes(company: &str) -> serde_json::Value {
json!({"company": company, "front_plan": "f-mini", "main_plan": "m-mini", "main_data_gib": 40})
}
for_both_sites!(
nobody_signed_in_buys_cancels_or_reads_an_account,
the_tenant_is_the_sessions_never_the_bodys,
a_forged_session_or_a_refused_ticket_is_refused,
a_request_replayed_past_the_budget_is_refused_before_the_shop,
an_oversized_body_is_refused_before_it_is_parsed,
);
/// RULE: a visitor or a machine is asked to sign in at every door that acts for an account,
/// and nothing is minted or checked out for it.
async fn nobody_signed_in_buys_cancels_or_reads_an_account(shape: Shape) {
let k = Kit::new(shape).await;
k.fake
.add_account("x14", "x14@mock.invalid", &[("disk_bytes", 5 << 30)]);
let account: [(&str, &str, Option<serde_json::Value>); 8] = [
("GET", "/api/account/subscription", None),
("GET", "/api/account/subscription/bundles", None),
("GET", "/api/account/subscription/history", None),
(
"GET",
"/api/account/subscription/status?reference=p-1",
None,
),
("GET", "/api/account/purchases", None),
(
"POST",
"/api/account/subscription/buy-bundle",
Some(json!({"bundle": BUNDLE})),
),
("POST", "/api/account/subscription/renew", None),
(
"POST",
"/api/account/subscription/cancel",
Some(json!({"reference": "p-1"})),
),
];
let private: [(&str, serde_json::Value); 6] = [
("/auth/private/change", sizes("acme")),
("/auth/private/renew", json!({"company": "acme"})),
("/auth/private/cancel", json!({"company": "acme"})),
("/auth/private/restore-front", json!({"company": "acme"})),
("/auth/private/sign-in-again", json!({"company": "acme"})),
(
"/auth/private/terminate",
json!({"company": "acme", "confirm": "acme"}),
),
];
for who in [None, Some("machine")] {
for (m, path, body) in &account {
let r = k.ask(m, path, who, body.clone()).await;
assert_eq!(
r.status,
StatusCode::UNAUTHORIZED,
"{who:?} {m} {path}: {:?}",
r.body
);
}
for (path, body) in &private {
let r = k.ask("POST", path, who, Some(body.clone())).await;
assert_eq!(
r.status,
StatusCode::UNAUTHORIZED,
"{who:?} POST {path}: {:?}",
r.body
);
}
}
// A machine does not start a private instance either (a visitor may: it makes an account).
let r = k
.ask(
"POST",
"/auth/private/start",
Some("machine"),
Some(sizes("acme")),
)
.await;
assert_eq!(r.status, StatusCode::UNAUTHORIZED, "{:?}", r.body);
assert!(
k.site.minted.lock().unwrap().is_empty(),
"no ticket was minted for nobody"
);
assert!(
k.fake.book().checkouts.is_empty(),
"nothing was checked out"
);
}
/// RULE: the account an order is for is the session's; a `tenant` or `account` in the body
/// is not read, and another account cannot read or cancel the order.
async fn the_tenant_is_the_sessions_never_the_bodys(shape: Shape) {
let k = Kit::new(shape).await;
k.fake
.add_account("x14", "x14@mock.invalid", &[("disk_bytes", 5 << 30)]);
k.fake
.add_account("y15", "y15@mock.invalid", &[("disk_bytes", 5 << 30)]);
let buy = k
.ask(
"POST",
"/api/account/subscription/buy-bundle",
Some("x14"),
Some(json!({"bundle": BUNDLE, "tenant": "y15", "account": "y15"})),
)
.await;
assert_eq!(buy.status, StatusCode::OK, "{:?}", buy.body);
{
let b = k.fake.book();
assert_eq!(b.checkouts.len(), 1);
assert!(
matches!(&b.checkouts[0].what, What::Bundle { tenant, .. } if tenant == "x14"),
"booked for the session's account: {:?}",
b.checkouts[0].what
);
}
assert!(
k.site
.minted
.lock()
.unwrap()
.iter()
.all(|(_, account, _)| account == "x14"),
"every ticket is the session's account's"
);
let reference = buy.body["reference"].as_str().unwrap().to_owned();
let theirs = k
.ask(
"GET",
&format!("/api/account/subscription/status?reference={reference}"),
Some("y15"),
None,
)
.await;
assert_eq!(theirs.status, StatusCode::NOT_FOUND, "{:?}", theirs.body);
let cancel = k
.ask(
"POST",
"/api/account/subscription/cancel",
Some("y15"),
Some(json!({"reference": reference})),
)
.await;
assert_eq!(cancel.status, StatusCode::NOT_FOUND, "{:?}", cancel.body);
assert_eq!(
k.fake.book().count("Purchase.Cancel"),
1,
"asked once, and refused"
);
let mine = k
.ask(
"GET",
&format!("/api/account/subscription/status?reference={reference}"),
Some("x14"),
None,
)
.await;
assert_eq!(
mine.body["state"], "pending",
"x14's order is untouched: {:?}",
mine.body
);
}
/// RULE: a session id that is not a checkout session never reaches the shop; a ticket the
/// shop refuses (forged, spent, under another key) answers 403, never a 502 that reads as an
/// outage.
async fn a_forged_session_or_a_refused_ticket_is_refused(shape: Shape) {
let k = Kit::new(shape).await;
for session in ["../x", "cs_x", "acct_zz", "cs_test_%3Cscript%3E0000"] {
let r = k
.ask(
"GET",
&format!("/auth/signup/status?session={session}"),
None,
None,
)
.await;
assert_eq!(
(r.status, r.error()),
(StatusCode::BAD_REQUEST, NOT_A_SESSION),
"{session}"
);
}
// The site's kept ladders (LADDER-2) read on their own clock; no session reached the shop.
assert!(
k.fake
.book()
.calls
.iter()
.all(|c| c == "Signup.Plans" || c == "EstateOrder.Ladder"),
"the shop was never asked: {:?}",
k.fake.book().calls
);
// A site whose tickets the shop refuses: an empty ticket stands for any the box does not
// accept (the fake answers Unauthenticated, as the box does for a forged one).
let fake = monetize_site_buy::testkit::FakeOneBox::spawn(shape.product(), sites::BEARER).await;
let site = TestSite::over(shape, &fake).with_minter(Arc::new(|_, _, _| Vec::new()));
let relay = Arc::new(Relay::new(site.clone(), fake.link()));
let k = Kit {
shape,
fake,
site,
relay,
emitted: Default::default(),
};
k.fake.add_account("x14", "x14@mock.invalid", &[]);
let r = k
.ask(
"POST",
"/auth/private/start",
Some("x14"),
Some(sizes("acme")),
)
.await;
assert_eq!(r.status, StatusCode::FORBIDDEN, "{:?}", r.body);
assert!(
k.fake.book().checkouts.is_empty(),
"nothing checked out on a refused ticket"
);
}
/// RULE: a request repeated past the door's budget is refused 429 before the shop is asked.
async fn a_request_replayed_past_the_budget_is_refused_before_the_shop(shape: Shape) {
let k = Kit::with_budget(shape, Some(2)).await;
let path = "/auth/signup/status?session=cs_test_fake0000000001";
for _ in 0..2 {
assert_ne!(
k.ask("GET", path, None, None).await.status,
StatusCode::TOO_MANY_REQUESTS
);
}
let third = k.ask("GET", path, None, None).await;
assert_eq!(
third.status,
StatusCode::TOO_MANY_REQUESTS,
"{:?}",
third.body
);
assert_eq!(
k.fake.book().count("Signup.Status"),
2,
"the third never reached the shop"
);
}
/// RULE: a body over [`BUY_BODY_MAX`] is refused 413 before it is parsed, on every POST door;
/// one at the cap is parsed (and refused on its content).
async fn an_oversized_body_is_refused_before_it_is_parsed(shape: Shape) {
let k = Kit::new(shape).await;
k.fake
.add_account("x14", "x14@mock.invalid", &[("disk_bytes", 5 << 30)]);
let padded = |n: usize| {
let base = json!({"bundle": ""}).to_string().len();
json!({"bundle": "x".repeat(n - base)})
};
assert_eq!(padded(BUY_BODY_MAX).to_string().len(), BUY_BODY_MAX);
for (path, who) in [
("/api/account/subscription/buy-bundle", Some("x14")),
("/auth/signup/start", None),
("/auth/private/start", None),
] {
let r = k
.ask("POST", path, who, Some(padded(BUY_BODY_MAX + 1)))
.await;
assert_eq!(
r.status,
StatusCode::PAYLOAD_TOO_LARGE,
"{path}: {:?}",
r.body
);
}
let at = k
.ask(
"POST",
"/api/account/subscription/buy-bundle",
Some("x14"),
Some(padded(BUY_BODY_MAX)),
)
.await;
assert_eq!(
at.status,
StatusCode::BAD_REQUEST,
"at the cap it is read, and is no bundle: {:?}",
at.body
);
assert!(k.fake.book().checkouts.is_empty());
}