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 | |
| 14 | use oxedyne_fe2o3_core::prelude::*; |
| 15 | use oxedyne_fe2o3_jdat::prelude::*; |
| 16 | use oxedyne_fe2o3_iop_db::api::Database; |
| 17 | use oxedyne_fe2o3_jdat::version::SemVer; |
| 18 | use oxedyne_fe2o3_net::ws::{ |
| 19 | core::WebSocketMessage, |
| 20 | handler::WebSocketHandler, |
| 21 | }; |
| 22 | use oxedyne_fe2o3_steel::srv::ws::{ |
| 23 | handler::AppWebSocketHandler, |
| 24 | syntax::WebSocketSyntax, |
| 25 | }; |
| 26 | |
| 27 | mod 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. |
| 32 | fn 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. |
| 43 | fn stranger_handler() -> AppWebSocketHandler { |
| 44 | AppWebSocketHandler::new(None) |
| 45 | .attach_sid(Some(fmt!("anon-stranger-sid"))) |
| 46 | .with_operator_authed(false) |
| 47 | } |
| 48 | |
| 49 | #[test] |
| 50 | fn 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] |
| 90 | fn 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. |
| 127 | fn operator_handler() -> AppWebSocketHandler { |
| 128 | AppWebSocketHandler::new(None) |
| 129 | .attach_sid(Some(fmt!("operator-sid"))) |
| 130 | .with_operator_authed(true) |
| 131 | } |
| 132 | |
| 133 | #[test] |
| 134 | fn 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 | } |