Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/monetize

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());
}