Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/tests/ws_unscoped_store.rs

6.6 KiB, 1 run

created by r1870400018:35549, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The raw `insert` and `get_data` WebSocket commands, driven the way an
2//! unauthenticated stranger reaches them.
3//!
4//! Every legitimate client store command beside them is either session-scoped
5//! (`sess_get`/`sess_put`) or authenticated and user-scoped (`user_get`/
6//! `user_put`). These two are neither: an arbitrary key, read or written, gated
7//! only on the vhost having a database. A connection that never logged in and
8//! carries no operator session drives them here against a real Ozone store, so
9//! the store must be left untouched and the secret left unread.
10//!
11//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
12//! Anthropic Claude
13
14use oxedyne_fe2o3_core::prelude::*;
15use oxedyne_fe2o3_jdat::prelude::*;
16use oxedyne_fe2o3_iop_db::api::Database;
17use oxedyne_fe2o3_jdat::version::SemVer;
18use oxedyne_fe2o3_net::ws::{
19 core::WebSocketMessage,
20 handler::WebSocketHandler,
21};
22use oxedyne_fe2o3_steel::srv::ws::{
23 handler::AppWebSocketHandler,
24 syntax::WebSocketSyntax,
25};
26
27mod common;
28
29// The text a `WebSocketMessage::Text` reply carries, or a failure if the reply
30// was anything else. The reply's leading word is the response command: `info`
31// on a successful insert, `data` on a read that returned, `error` on a refusal.
32fn reply_text(msg: Option<WebSocketMessage>) -> Outcome<String> {
33 match msg {
34 Some(WebSocketMessage::Text(s)) => Ok(s),
35 other => Err(err!(
36 "Expected a text reply, got {:?}.", other; Test, Mismatch)),
37 }
38}
39
40// A handler as a fresh, unauthenticated connection presents it: an anonymous
41// session id, no operator principal resolved. This is exactly what the router
42// hands `handle_text` for a stranger's socket.
43fn stranger_handler() -> AppWebSocketHandler {
44 AppWebSocketHandler::new(None)
45 .attach_sid(Some(fmt!("anon-stranger-sid")))
46 .with_operator_authed(false)
47}
48
49#[test]
50fn unauthenticated_insert_is_refused() -> Outcome<()> {
51 let (db, uid, _tmp) = match common::test_db() {
52 Ok(t) => t,
53 Err(e) => {
54 println!("no test database available, skipping: {}", e);
55 return Ok(());
56 }
57 };
58 let handle = (db.clone(), uid);
59 let syntax = res!(WebSocketSyntax::new(
60 "steel_ws", &SemVer::new(0, 1, 0), "unscoped store test"));
61 let id = fmt!("test");
62
63 // A key another part of the app trusts: the console reads `publish/admins`
64 // to decide who administers the site. Seed it with the one true admin.
65 let admins_key = dat!("publish/admins");
66 {
67 let g = res!(db.write().map_err(|_| err!("poisoned"; Test)));
68 res!(g.insert(admins_key.clone(), dat!(vec![dat!("real-admin")]), uid, None));
69 }
70
71 // A stranger tries to overwrite the admin list with their own handle.
72 let mut h = stranger_handler();
73 let txt = fmt!("insert (str|publish/admins) (str|attacker-owns-this)");
74 let reply = res!(reply_text(res!(h.handle_text(
75 txt, Some(handle.clone()), syntax.clone(), &id))));
76 assert!(reply.starts_with("error"),
77 "unauthenticated insert was not refused, replied: {}", reply);
78
79 // The property, not just the reply: the admin list is untouched.
80 let g = res!(db.read().map_err(|_| err!("poisoned"; Test)));
81 match res!(g.get(&admins_key, None)) {
82 Some((v, _)) => assert_eq!(v, dat!(vec![dat!("real-admin")]),
83 "the admin list was overwritten by an unauthenticated caller"),
84 None => panic!("the seeded admin list vanished"),
85 }
86 Ok(())
87}
88
89#[test]
90fn unauthenticated_get_data_is_refused() -> Outcome<()> {
91 let (db, uid, _tmp) = match common::test_db() {
92 Ok(t) => t,
93 Err(e) => {
94 println!("no test database available, skipping: {}", e);
95 return Ok(());
96 }
97 };
98 let handle = (db.clone(), uid);
99 let syntax = res!(WebSocketSyntax::new(
100 "steel_ws", &SemVer::new(0, 1, 0), "unscoped store test"));
101 let id = fmt!("test");
102
103 // A stand-in for credential material: a stored user record's hash, under
104 // the `user:<name>` key the auth path writes.
105 let secret_key = dat!("user:victim");
106 let secret_val = dat!("kdf-hash-secret-material");
107 {
108 let g = res!(db.write().map_err(|_| err!("poisoned"; Test)));
109 res!(g.insert(secret_key.clone(), secret_val.clone(), uid, None));
110 }
111
112 // A stranger tries to read that arbitrary key.
113 let mut h = stranger_handler();
114 let txt = fmt!("get_data (str|\"user:victim\")");
115 let reply = res!(reply_text(res!(h.handle_text(
116 txt, Some(handle.clone()), syntax.clone(), &id))));
117 assert!(reply.starts_with("error"),
118 "unauthenticated get_data was not refused, replied: {}", reply);
119 assert!(!reply.contains("kdf-hash-secret-material"),
120 "unauthenticated get_data returned the secret, replied: {}", reply);
121 Ok(())
122}
123
124// The gate is on the operator session, not a wall: an authenticated operator
125// still reaches the raw commands, so this proves the refusals above discriminate
126// on the principal rather than disabling the commands outright.
127fn operator_handler() -> AppWebSocketHandler {
128 AppWebSocketHandler::new(None)
129 .attach_sid(Some(fmt!("operator-sid")))
130 .with_operator_authed(true)
131}
132
133#[test]
134fn operator_insert_and_get_data_are_allowed() -> Outcome<()> {
135 let (db, uid, _tmp) = match common::test_db() {
136 Ok(t) => t,
137 Err(e) => {
138 println!("no test database available, skipping: {}", e);
139 return Ok(());
140 }
141 };
142 let handle = (db.clone(), uid);
143 let syntax = res!(WebSocketSyntax::new(
144 "steel_ws", &SemVer::new(0, 1, 0), "unscoped store test"));
145 let id = fmt!("test");
146
147 // Seed a key directly, then read it back through the operator's get_data.
148 let key = dat!("publish/index");
149 {
150 let g = res!(db.write().map_err(|_| err!("poisoned"; Test)));
151 res!(g.insert(key.clone(), dat!("operator-may-read-this"), uid, None));
152 }
153 let mut h = operator_handler();
154 let reply = res!(reply_text(res!(h.handle_text(
155 fmt!("get_data (str|publish/index)"),
156 Some(handle.clone()), syntax.clone(), &id))));
157 assert!(reply.starts_with("data") && reply.contains("operator-may-read-this"),
158 "operator get_data was refused or returned nothing, replied: {}", reply);
159
160 // And the operator's insert lands.
161 let mut h = operator_handler();
162 let reply = res!(reply_text(res!(h.handle_text(
163 fmt!("insert (str|publish/index) (str|operator-wrote-this)"),
164 Some(handle.clone()), syntax.clone(), &id))));
165 assert!(reply.starts_with("info"),
166 "operator insert was refused, replied: {}", reply);
167 Ok(())
168}