Skip to content

Commit 0b7cf3e

Browse files
committed
security: harden session endpoints
- Atomic balance deduction prevents race condition double-spend - Auth required on close/status endpoints (API key or dashboard session) - Token sent via Authorization header, not URL query string - Remove timestamp from token generation (pure CSPRNG UUIDs) - Rate limit session open (30s/req, burst 3) - Raise minimum deposit from 1000 to 10000 zatoshis - Validate refund address format on session open - Return 503 on session validation failure instead of silent fallthrough - Purge closed/depleted sessions in periodic data cleanup
1 parent 5430ed1 commit 0b7cf3e

5 files changed

Lines changed: 306 additions & 61 deletions

File tree

scripts/seed_mock_events.sh

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
#!/usr/bin/env bash
2+
# Seeds mock events, invoices, and tickets into the local DB for UI testing.
3+
# Usage: ./scripts/seed_mock_events.sh (seed)
4+
# ./scripts/seed_mock_events.sh --clean (remove seeded data)
5+
set -euo pipefail
6+
7+
DB="${1:-cipherpay.db}"
8+
MERCHANT="a3567834-b9dc-4acb-b379-bd506f976060"
9+
10+
# Deterministic IDs so we can clean up reliably
11+
PROD_SOLDOUT="mock-prod-soldout"
12+
PROD_PAST="mock-prod-past"
13+
PROD_CANCEL="mock-prod-cancel"
14+
15+
EVT_SOLDOUT="mock-evt-soldout"
16+
EVT_PAST="mock-evt-past"
17+
EVT_CANCEL="mock-evt-cancel"
18+
19+
PRC_SOLDOUT_GA="mock-prc-soldout-ga"
20+
PRC_SOLDOUT_VIP="mock-prc-soldout-vip"
21+
PRC_PAST_GA="mock-prc-past-ga"
22+
PRC_CANCEL_GA="mock-prc-cancel-ga"
23+
PRC_CANCEL_VIP="mock-prc-cancel-vip"
24+
25+
ADDR="utest1mockaddress000000000000000000000000000000000000000000000000000000000000000000000000000000"
26+
RECV="deadbeefcafe0000000000000000000000000000000000000000000000000000000000000000000000000000"
27+
28+
if [ "${1:-}" = "--clean" ]; then
29+
echo "Cleaning mock data..."
30+
sqlite3 "$DB" <<'SQL'
31+
DELETE FROM tickets WHERE id LIKE 'mock-tkt-%';
32+
DELETE FROM invoices WHERE id LIKE 'mock-inv-%';
33+
DELETE FROM prices WHERE id LIKE 'mock-prc-%';
34+
DELETE FROM events WHERE id LIKE 'mock-evt-%';
35+
DELETE FROM products WHERE id LIKE 'mock-prod-%';
36+
SQL
37+
echo "Done."
38+
exit 0
39+
fi
40+
41+
echo "Seeding mock events for merchant $MERCHANT ..."
42+
43+
sqlite3 "$DB" <<SQL
44+
45+
-- ═══════════════════════════════════════════════════════════════
46+
-- 1. SOLD-OUT EVENT ─ "Privacy Masterclass" (capacity 3 GA + 2 VIP, all sold)
47+
-- ═══════════════════════════════════════════════════════════════
48+
49+
INSERT OR REPLACE INTO products (id, merchant_id, slug, name, active)
50+
VALUES ('$PROD_SOLDOUT', '$MERCHANT', 'privacy-masterclass', 'Privacy Masterclass', 1);
51+
52+
INSERT OR REPLACE INTO events (id, merchant_id, product_id, title, description, event_date, event_location, status)
53+
VALUES ('$EVT_SOLDOUT', '$MERCHANT', '$PROD_SOLDOUT',
54+
'Privacy Masterclass', 'Hands-on workshop covering CoinJoin, zk proofs, and metadata resistance.',
55+
'2026-05-15T14:00:00Z', 'Berlin, Germany', 'active');
56+
57+
INSERT OR REPLACE INTO prices (id, product_id, currency, unit_amount, label, max_quantity, active)
58+
VALUES ('$PRC_SOLDOUT_GA', '$PROD_SOLDOUT', 'EUR', 15.0, 'General Admission', 3, 1);
59+
60+
INSERT OR REPLACE INTO prices (id, product_id, currency, unit_amount, label, max_quantity, active)
61+
VALUES ('$PRC_SOLDOUT_VIP', '$PROD_SOLDOUT', 'EUR', 40.0, 'VIP', 2, 1);
62+
63+
-- 3 GA confirmed invoices
64+
INSERT OR REPLACE INTO invoices (id, merchant_id, memo_code, product_id, product_name, price_eur, price_zec, zec_rate_at_creation, payment_address, status, expires_at, diversifier_index, orchard_receiver_hex, price_zatoshis, received_zatoshis, price_id, confirmed_at)
65+
VALUES
66+
('mock-inv-so-ga1', '$MERCHANT', 'CP-MOCK-SO1', '$PROD_SOLDOUT', 'Privacy Masterclass', 15.0, 0.3, 50.0, '$ADDR', 'confirmed', '2026-04-01T00:00:00Z', 1001, '$RECV', 30000000, 30000000, '$PRC_SOLDOUT_GA', '2026-03-10T10:00:00Z'),
67+
('mock-inv-so-ga2', '$MERCHANT', 'CP-MOCK-SO2', '$PROD_SOLDOUT', 'Privacy Masterclass', 15.0, 0.3, 50.0, '$ADDR', 'confirmed', '2026-04-01T00:00:00Z', 1002, '$RECV', 30000000, 30000000, '$PRC_SOLDOUT_GA', '2026-03-11T10:00:00Z'),
68+
('mock-inv-so-ga3', '$MERCHANT', 'CP-MOCK-SO3', '$PROD_SOLDOUT', 'Privacy Masterclass', 15.0, 0.3, 50.0, '$ADDR', 'confirmed', '2026-04-01T00:00:00Z', 1003, '$RECV', 30000000, 30000000, '$PRC_SOLDOUT_GA', '2026-03-12T10:00:00Z');
69+
70+
-- 2 VIP confirmed invoices
71+
INSERT OR REPLACE INTO invoices (id, merchant_id, memo_code, product_id, product_name, price_eur, price_zec, zec_rate_at_creation, payment_address, status, expires_at, diversifier_index, orchard_receiver_hex, price_zatoshis, received_zatoshis, price_id, confirmed_at)
72+
VALUES
73+
('mock-inv-so-vip1', '$MERCHANT', 'CP-MOCK-SO4', '$PROD_SOLDOUT', 'Privacy Masterclass', 40.0, 0.8, 50.0, '$ADDR', 'confirmed', '2026-04-01T00:00:00Z', 1004, '$RECV', 80000000, 80000000, '$PRC_SOLDOUT_VIP', '2026-03-13T10:00:00Z'),
74+
('mock-inv-so-vip2', '$MERCHANT', 'CP-MOCK-SO5', '$PROD_SOLDOUT', 'Privacy Masterclass', 40.0, 0.8, 50.0, '$ADDR', 'confirmed', '2026-04-01T00:00:00Z', 1005, '$RECV', 80000000, 80000000, '$PRC_SOLDOUT_VIP', '2026-03-14T10:00:00Z');
75+
76+
-- Tickets for all 5 sold
77+
INSERT OR REPLACE INTO tickets (id, invoice_id, product_id, price_id, merchant_id, code, status)
78+
VALUES
79+
('mock-tkt-so1', 'mock-inv-so-ga1', '$PROD_SOLDOUT', '$PRC_SOLDOUT_GA', '$MERCHANT', 'tkt_mock_so_ga_001', 'valid'),
80+
('mock-tkt-so2', 'mock-inv-so-ga2', '$PROD_SOLDOUT', '$PRC_SOLDOUT_GA', '$MERCHANT', 'tkt_mock_so_ga_002', 'valid'),
81+
('mock-tkt-so3', 'mock-inv-so-ga3', '$PROD_SOLDOUT', '$PRC_SOLDOUT_GA', '$MERCHANT', 'tkt_mock_so_ga_003', 'valid'),
82+
('mock-tkt-so4', 'mock-inv-so-vip1', '$PROD_SOLDOUT', '$PRC_SOLDOUT_VIP', '$MERCHANT', 'tkt_mock_so_vip_001', 'valid'),
83+
('mock-tkt-so5', 'mock-inv-so-vip2', '$PROD_SOLDOUT', '$PRC_SOLDOUT_VIP', '$MERCHANT', 'tkt_mock_so_vip_002', 'used');
84+
85+
86+
-- ═══════════════════════════════════════════════════════════════
87+
-- 2. PAST EVENT ─ "Zcash Dev Summit 2025" (event date in the past)
88+
-- ═══════════════════════════════════════════════════════════════
89+
90+
INSERT OR REPLACE INTO products (id, merchant_id, slug, name, active)
91+
VALUES ('$PROD_PAST', '$MERCHANT', 'zcash-dev-summit-2025', 'Zcash Dev Summit 2025', 0);
92+
93+
INSERT OR REPLACE INTO events (id, merchant_id, product_id, title, description, event_date, event_location, status)
94+
VALUES ('$EVT_PAST', '$MERCHANT', '$PROD_PAST',
95+
'Zcash Dev Summit 2025', 'Annual developer summit. Zebra, librustzcash, wallet SDK deep dives.',
96+
'2025-11-20T09:00:00Z', 'Denver, CO', 'past');
97+
98+
INSERT OR REPLACE INTO prices (id, product_id, currency, unit_amount, label, max_quantity, active)
99+
VALUES ('$PRC_PAST_GA', '$PROD_PAST', 'USD', 50.0, 'General Admission', 30, 0);
100+
101+
-- 8 confirmed invoices (historical attendance)
102+
INSERT OR REPLACE INTO invoices (id, merchant_id, memo_code, product_id, product_name, price_eur, price_zec, zec_rate_at_creation, payment_address, status, expires_at, diversifier_index, orchard_receiver_hex, price_zatoshis, received_zatoshis, price_id, confirmed_at)
103+
VALUES
104+
('mock-inv-past1', '$MERCHANT', 'CP-MOCK-P1', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1010, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-15T10:00:00Z'),
105+
('mock-inv-past2', '$MERCHANT', 'CP-MOCK-P2', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1011, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-16T10:00:00Z'),
106+
('mock-inv-past3', '$MERCHANT', 'CP-MOCK-P3', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1012, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-17T10:00:00Z'),
107+
('mock-inv-past4', '$MERCHANT', 'CP-MOCK-P4', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1013, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-18T10:00:00Z'),
108+
('mock-inv-past5', '$MERCHANT', 'CP-MOCK-P5', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1014, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-19T10:00:00Z'),
109+
('mock-inv-past6', '$MERCHANT', 'CP-MOCK-P6', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1015, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-20T10:00:00Z'),
110+
('mock-inv-past7', '$MERCHANT', 'CP-MOCK-P7', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1016, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-21T10:00:00Z'),
111+
('mock-inv-past8', '$MERCHANT', 'CP-MOCK-P8', '$PROD_PAST', 'Zcash Dev Summit 2025', 45.0, 1.0, 45.0, '$ADDR', 'confirmed', '2025-10-01T00:00:00Z', 1017, '$RECV', 100000000, 100000000, '$PRC_PAST_GA', '2025-09-22T10:00:00Z');
112+
113+
-- All 8 tickets used (past event, everyone checked in)
114+
INSERT OR REPLACE INTO tickets (id, invoice_id, product_id, price_id, merchant_id, code, status, used_at)
115+
VALUES
116+
('mock-tkt-past1', 'mock-inv-past1', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_001', 'used', '2025-11-20T09:15:00Z'),
117+
('mock-tkt-past2', 'mock-inv-past2', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_002', 'used', '2025-11-20T09:20:00Z'),
118+
('mock-tkt-past3', 'mock-inv-past3', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_003', 'used', '2025-11-20T09:25:00Z'),
119+
('mock-tkt-past4', 'mock-inv-past4', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_004', 'used', '2025-11-20T09:30:00Z'),
120+
('mock-tkt-past5', 'mock-inv-past5', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_005', 'used', '2025-11-20T09:35:00Z'),
121+
('mock-tkt-past6', 'mock-inv-past6', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_006', 'used', '2025-11-20T09:40:00Z'),
122+
('mock-tkt-past7', 'mock-inv-past7', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_007', 'used', '2025-11-20T09:45:00Z'),
123+
('mock-tkt-past8', 'mock-inv-past8', '$PROD_PAST', '$PRC_PAST_GA', '$MERCHANT', 'tkt_mock_past_008', 'used', '2025-11-20T09:50:00Z');
124+
125+
126+
-- ═══════════════════════════════════════════════════════════════
127+
-- 3. CANCELLED EVENT ─ "Crypto Privacy Workshop" (refund queue scenario)
128+
-- ═══════════════════════════════════════════════════════════════
129+
130+
INSERT OR REPLACE INTO products (id, merchant_id, slug, name, active)
131+
VALUES ('$PROD_CANCEL', '$MERCHANT', 'crypto-privacy-workshop', 'Crypto Privacy Workshop', 0);
132+
133+
INSERT OR REPLACE INTO events (id, merchant_id, product_id, title, description, event_date, event_location, status)
134+
VALUES ('$EVT_CANCEL', '$MERCHANT', '$PROD_CANCEL',
135+
'Crypto Privacy Workshop', 'Cancelled due to venue issues.',
136+
'2026-06-10T18:00:00Z', 'Lisbon, Portugal', 'cancelled');
137+
138+
INSERT OR REPLACE INTO prices (id, product_id, currency, unit_amount, label, max_quantity, active)
139+
VALUES ('$PRC_CANCEL_GA', '$PROD_CANCEL', 'EUR', 20.0, 'General Admission', 15, 0);
140+
141+
INSERT OR REPLACE INTO prices (id, product_id, currency, unit_amount, label, max_quantity, active)
142+
VALUES ('$PRC_CANCEL_VIP', '$PROD_CANCEL', 'EUR', 55.0, 'VIP', 5, 0);
143+
144+
-- 4 confirmed invoices: 2 have refund_address (refund queue), 1 already refunded, 1 without refund address
145+
INSERT OR REPLACE INTO invoices (id, merchant_id, memo_code, product_id, product_name, price_eur, price_zec, zec_rate_at_creation, payment_address, status, expires_at, diversifier_index, orchard_receiver_hex, price_zatoshis, received_zatoshis, price_id, confirmed_at, refund_address)
146+
VALUES
147+
('mock-inv-cx-ga1', '$MERCHANT', 'CP-MOCK-CX1', '$PROD_CANCEL', 'Crypto Privacy Workshop', 20.0, 0.4, 50.0, '$ADDR', 'confirmed', '2026-05-01T00:00:00Z', 1020, '$RECV', 40000000, 40000000, '$PRC_CANCEL_GA', '2026-04-10T10:00:00Z', 'u1refund_alice_0000000000000000000000000000000000000000000000000000000000000000000000000000000000'),
148+
('mock-inv-cx-ga2', '$MERCHANT', 'CP-MOCK-CX2', '$PROD_CANCEL', 'Crypto Privacy Workshop', 20.0, 0.4, 50.0, '$ADDR', 'confirmed', '2026-05-01T00:00:00Z', 1021, '$RECV', 40000000, 40000000, '$PRC_CANCEL_GA', '2026-04-11T10:00:00Z', 'u1refund_bob_000000000000000000000000000000000000000000000000000000000000000000000000000000000000'),
149+
('mock-inv-cx-vip1', '$MERCHANT', 'CP-MOCK-CX3', '$PROD_CANCEL', 'Crypto Privacy Workshop', 55.0, 1.1, 50.0, '$ADDR', 'refunded', '2026-05-01T00:00:00Z', 1022, '$RECV', 110000000, 110000000, '$PRC_CANCEL_VIP', '2026-04-12T10:00:00Z', 'u1refund_carol_00000000000000000000000000000000000000000000000000000000000000000000000000000000000'),
150+
('mock-inv-cx-ga3', '$MERCHANT', 'CP-MOCK-CX4', '$PROD_CANCEL', 'Crypto Privacy Workshop', 20.0, 0.4, 50.0, '$ADDR', 'confirmed', '2026-05-01T00:00:00Z', 1023, '$RECV', 40000000, 40000000, '$PRC_CANCEL_GA', '2026-04-13T10:00:00Z', NULL);
151+
152+
-- All tickets voided (event was cancelled)
153+
INSERT OR REPLACE INTO tickets (id, invoice_id, product_id, price_id, merchant_id, code, status)
154+
VALUES
155+
('mock-tkt-cx1', 'mock-inv-cx-ga1', '$PROD_CANCEL', '$PRC_CANCEL_GA', '$MERCHANT', 'tkt_mock_cx_001', 'void'),
156+
('mock-tkt-cx2', 'mock-inv-cx-ga2', '$PROD_CANCEL', '$PRC_CANCEL_GA', '$MERCHANT', 'tkt_mock_cx_002', 'void'),
157+
('mock-tkt-cx3', 'mock-inv-cx-vip1', '$PROD_CANCEL', '$PRC_CANCEL_VIP', '$MERCHANT', 'tkt_mock_cx_003', 'void'),
158+
('mock-tkt-cx4', 'mock-inv-cx-ga3', '$PROD_CANCEL', '$PRC_CANCEL_GA', '$MERCHANT', 'tkt_mock_cx_004', 'void');
159+
160+
SQL
161+
162+
echo ""
163+
echo "Seeded:"
164+
echo " 3 events (sold-out, past, cancelled)"
165+
echo " 5 products/prices"
166+
echo " 17 invoices"
167+
echo " 17 tickets"
168+
echo ""
169+
echo "To clean up: ./scripts/seed_mock_events.sh --clean"

src/api/mod.rs

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
2828
.finish()
2929
.expect("Failed to build auth rate limiter");
3030

31+
let session_rate_limit = GovernorConfigBuilder::default()
32+
.seconds_per_request(30)
33+
.burst_size(3)
34+
.finish()
35+
.expect("Failed to build session rate limiter");
36+
3137
cfg.service(
3238
web::scope("/api")
3339
.route("/health", web::get().to(health))
@@ -106,10 +112,13 @@ pub fn configure(cfg: &mut web::ServiceConfig) {
106112
// x402 facilitator
107113
.route("/x402/verify", web::post().to(x402::verify))
108114
// Session endpoints (agentic prepaid credit)
109-
.route("/sessions/open", web::post().to(sessions::open))
110-
.route("/sessions/validate", web::get().to(sessions::validate))
111-
.route("/sessions/{id}", web::get().to(sessions::get_status))
112-
.route("/sessions/{id}/close", web::post().to(sessions::close))
115+
.service(
116+
web::scope("/sessions")
117+
.route("/open", web::post().to(sessions::open).wrap(Governor::new(&session_rate_limit)))
118+
.route("/validate", web::get().to(sessions::validate))
119+
.route("/{id}", web::get().to(sessions::get_status))
120+
.route("/{id}/close", web::post().to(sessions::close))
121+
)
113122
// Admin endpoints (protected by ADMIN_KEY)
114123
.route("/admin/auth", web::post().to(admin::auth_check))
115124
.route("/admin/stats", web::get().to(admin::stats))

src/api/sessions.rs

Lines changed: 68 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,14 @@ pub async fn open(
2626
}));
2727
}
2828

29+
if let Some(ref addr) = body.refund_address {
30+
if !addr.is_empty() {
31+
if let Err(e) = crate::validation::validate_zcash_address("refund_address", addr) {
32+
return HttpResponse::BadRequest().json(e.to_json());
33+
}
34+
}
35+
}
36+
2937
if crate::sessions::txid_already_used(pool.get_ref(), &body.txid).await {
3038
return HttpResponse::Conflict().json(serde_json::json!({
3139
"error": "This transaction has already been used to open a session"
@@ -84,9 +92,9 @@ pub async fn open(
8492
.map(|o| o.amount_zatoshis as i64)
8593
.sum();
8694

87-
if total_zatoshis < 1000 {
95+
if total_zatoshis < 10_000 {
8896
return HttpResponse::BadRequest().json(serde_json::json!({
89-
"error": "Deposit too small — minimum 1000 zatoshis (0.00001 ZEC)"
97+
"error": "Deposit too small — minimum 10,000 zatoshis (0.0001 ZEC)"
9098
}));
9199
}
92100

@@ -115,12 +123,51 @@ pub async fn open(
115123
}
116124
}
117125

126+
/// Resolve the merchant owning this session, requiring API key or dashboard auth.
127+
async fn require_session_owner(
128+
req: &HttpRequest,
129+
pool: &SqlitePool,
130+
session_id: &str,
131+
) -> Result<(), HttpResponse> {
132+
let session = crate::sessions::get_session(pool, session_id).await
133+
.map_err(|_| HttpResponse::InternalServerError().json(serde_json::json!({"error": "Internal error"})))?
134+
.ok_or_else(|| HttpResponse::NotFound().json(serde_json::json!({"error": "Session not found"})))?;
135+
136+
// Try dashboard session auth
137+
if let Some(merchant) = crate::api::auth::resolve_session(req, &web::Data::new(pool.clone())).await {
138+
if merchant.id == session.merchant_id {
139+
return Ok(());
140+
}
141+
}
142+
143+
// Try API key auth
144+
if let Some(auth_header) = req.headers().get("Authorization") {
145+
if let Ok(auth_str) = auth_header.to_str() {
146+
let key = auth_str.strip_prefix("Bearer ").unwrap_or(auth_str).trim();
147+
let enc_key = req.app_data::<web::Data<Config>>()
148+
.map(|c| c.encryption_key.clone()).unwrap_or_default();
149+
if let Ok(Some(merchant)) = crate::merchants::authenticate(pool, key, &enc_key).await {
150+
if merchant.id == session.merchant_id {
151+
return Ok(());
152+
}
153+
}
154+
}
155+
}
156+
157+
Err(HttpResponse::Unauthorized().json(serde_json::json!({"error": "Not authorized for this session"})))
158+
}
159+
118160
pub async fn get_status(
161+
req: HttpRequest,
119162
pool: web::Data<SqlitePool>,
120163
path: web::Path<String>,
121164
) -> HttpResponse {
122165
let session_id = path.into_inner();
123166

167+
if let Err(resp) = require_session_owner(&req, pool.get_ref(), &session_id).await {
168+
return resp;
169+
}
170+
124171
match crate::sessions::get_summary(pool.get_ref(), &session_id).await {
125172
Ok(Some(summary)) => {
126173
HttpResponse::Ok().json(serde_json::json!({
@@ -144,11 +191,16 @@ pub async fn get_status(
144191
}
145192

146193
pub async fn close(
194+
req: HttpRequest,
147195
pool: web::Data<SqlitePool>,
148196
path: web::Path<String>,
149197
) -> HttpResponse {
150198
let session_id = path.into_inner();
151199

200+
if let Err(resp) = require_session_owner(&req, pool.get_ref(), &session_id).await {
201+
return resp;
202+
}
203+
152204
match crate::sessions::close_session(pool.get_ref(), &session_id).await {
153205
Ok(Some(summary)) => {
154206
let mut resp = serde_json::json!({
@@ -246,18 +298,26 @@ pub async fn validate(
246298
req: HttpRequest,
247299
pool: web::Data<SqlitePool>,
248300
) -> HttpResponse {
249-
let token = req.query_string()
250-
.split('&')
251-
.find_map(|pair| {
252-
let (k, v) = pair.split_once('=')?;
253-
if k == "token" { Some(v.to_string()) } else { None }
301+
// Accept token from Authorization: Bearer header (preferred) or query param (legacy)
302+
let token = req.headers().get("Authorization")
303+
.and_then(|v| v.to_str().ok())
304+
.and_then(|s| s.strip_prefix("Bearer "))
305+
.map(|s| s.trim().to_string())
306+
.filter(|s| s.starts_with("cps_"))
307+
.or_else(|| {
308+
req.query_string()
309+
.split('&')
310+
.find_map(|pair| {
311+
let (k, v) = pair.split_once('=')?;
312+
if k == "token" { Some(v.to_string()) } else { None }
313+
})
254314
});
255315

256316
let token = match token {
257317
Some(t) if !t.is_empty() => t,
258318
_ => {
259319
return HttpResponse::BadRequest().json(serde_json::json!({
260-
"error": "token query parameter required"
320+
"error": "Bearer token required — use Authorization: Bearer header or token query parameter"
261321
}));
262322
}
263323
};

0 commit comments

Comments
 (0)