okami.mom/albedo
git clone https://git.nkp.pet/okami.mom/albedo
61c43ca
main
1"""Signed webhook ingress deduplicates deliveries and wakes a durable session.""" 2 3import hashlib 4import hmac 5import json 6import sqlite3 7import time 8import unittest 9import urllib .error 10import urllib .request 11 12from daemon_test import delayed_request ,refused_request 13from harness import Albedo ,Provider ,python ,text 14 15 16class WebhookTests (unittest .TestCase ): 17def setUp (self ): 18def script (request ): 19latest = request ["input" ][- 1 ] 20if latest .get ("role" )== "user" and "probe webhook binding" in str ( 21latest .get ("content" ,"" ) 22 ): 23code = ( 24"hooks = await webhooks.list()\n" 25f"assert any(h.name == { self . hook_name !r } for h in hooks)\n" 26f"payload = await webhooks.delivery(' { self . delivery } ')\n" 27"assert 'down' in payload.body\n" 28"print('WEBHOOK_BINDING_OK')" 29 ) 30return python (code ) 31return text ("acknowledged" ) 32 33self .provider = Provider (script ) 34self .addCleanup (self .provider .close ) 35self .app = Albedo (self .provider ,protocol = "responses" ) 36self .app .__enter__ () 37self .addCleanup (self .app .__exit__ ,None ,None ,None ) 38self .session = self .app .session () 39self .hook_name = f"outage- { self . provider . route } " 40with self .app .api ( 41"/extensions/webhooks/hooks" , 42 {"name" :self .hook_name ,"session_id" :self .session }, 43 )as response : 44created = json .load (response ) 45self .hook ,self .secret = created ["resource" ]["value" ],created ["secret" ] 46self .hook_route = f"/extensions/webhooks/hooks/ { self . hook [ 'id' ] } " 47self .addCleanup (self .delete_hook ) 48self .payload = b'{"status":"down"}' 49self .delivery_path = self .hook_route + "/deliveries" 50self .url = self .app .base + self .delivery_path 51 52def delete_hook (self ): 53with self .app .api (self .hook_route + "?view=configuration" )as response : 54json .load (response ) 55revision = response .headers ["ETag" ] 56self .app .api ( 57self .hook_route + "?view=configuration" , 58method = "DELETE" , 59headers = {"If-Match" :revision }, 60 ).close () 61 62def allow_agent (self ): 63route = f"/extensions/webhooks/permissions/ { self . session } " 64with self .app .api (route )as response : 65json .load (response ) 66revision = response .headers ["ETag" ] 67self .app .api ( 68route , 69 {"agent_manage" :True }, 70method = "PATCH" , 71headers = {"If-Match" :revision }, 72 ).close () 73 74def delivery_count (self ): 75with sqlite3 .connect (self .app .home / "albedo.sqlite" ,timeout = 10 )as database : 76return database .execute ( 77"SELECT count(*) FROM webhook_deliveries WHERE hook = ?" , 78 (self .hook ["id" ],), 79 ).fetchone ()[0 ] 80 81def send ( 82self ,body = None ,* ,signature = None ,event_id = "alert-1" ,origin = None ,token = None 83 ): 84body = self .payload if body is None else body 85signature = ( 86signature 87or "sha256=" 88+ hmac .new (self .secret .encode (),body ,hashlib .sha256 ).hexdigest () 89 ) 90headers = {"X-Albedo-Signature" :signature ,"X-Albedo-Event-Id" :event_id } 91if token : 92headers ["Authorization" ]= "Bearer " + token 93if origin : 94headers ["Origin" ]= origin 95request = urllib .request .Request (self .url ,data = body ,headers = headers ) 96return urllib .request .urlopen (request ,timeout = 25 ) 97 98def test_hook_configuration_is_conditional_and_never_discloses_the_secret (self ): 99resource = self .hook_route + "?view=configuration" 100with self .app .api (resource )as response : 101original = json .load (response ) 102revision = response .headers ["ETag" ] 103self .assertNotIn (self .secret ,json .dumps (original )) 104patch = { 105"name" :self .hook_name + "-edited" , 106"enabled" :False , 107"signature_header" :"x-observed-signature" , 108"signature_prefix" :"hmac=" , 109 } 110with self .app .api ( 111resource ,patch ,method = "PATCH" ,headers = {"If-Match" :revision } 112 )as response : 113changed = json .load (response ) 114self .assertNotIn (self .secret ,json .dumps (changed )) 115current = changed ["resource" ]["value" ] 116for key ,value in patch .items (): 117self .assertEqual (current [key ],value ) 118with self .assertRaises (urllib .error .HTTPError )as rejected : 119self .app .api ( 120resource , 121 {"name" :"stale-must-not-win" ,"enabled" :True }, 122method = "PATCH" , 123headers = {"If-Match" :revision }, 124 ) 125self .assertEqual (rejected .exception .code ,412 ) 126with self .app .api (resource )as response : 127self .assertEqual (json .load (response ),current ) 128with self .app .api (self .hook_route )as response : 129self .assertNotIn (self .secret ,response .read ().decode ("utf-8" )) 130with self .assertRaises (urllib .error .HTTPError )as rejected : 131self .app .api ( 132"/extensions/webhooks/hooks" , 133 {"name" :current ["name" ],"session_id" :self .session }, 134 ) 135self .assertEqual (rejected .exception .code ,409 ) 136self .assertFalse (self .provider .requests ) 137 138def test_signature_origin_and_idempotency_are_enforced (self ): 139with self .assertRaises (urllib .error .HTTPError )as rejected : 140self .send (signature = "sha256=wrong" ,token = self .app .connection ["token" ]) 141self .assertEqual (rejected .exception .code ,403 ) 142with self .assertRaises (urllib .error .HTTPError )as rejected : 143self .send (origin = "https://attacker.example" ) 144self .assertEqual (rejected .exception .code ,403 ) 145self .assertFalse (self .provider .requests ) 146self .assertEqual (self .delivery_count (),0 ) 147with self .send ()as response : 148self .assertEqual (response .status ,202 ) 149delivery = json .load (response )["delivery_id" ] 150with self .send ()as response : 151self .assertEqual (json .load (response )["delivery_id" ],delivery ) 152with self .assertRaises (urllib .error .HTTPError )as rejected : 153self .send (body = b"different" ) 154self .assertEqual (rejected .exception .code ,409 ) 155deadline = time .monotonic ()+ 40 156while time .monotonic ()< deadline and not self .provider .requests : 157time .sleep (0.25 ) 158self .assertEqual ( 159len (self .provider .requests ),1 ,"retry should wake the session once" 160 ) 161self .assertIn ( 162f"[webhook { self . hook_name } #" ,json .dumps (self .provider .requests [0 ]) 163 ) 164with self .app .api ( 165f"/extensions/webhooks/hooks?session_id= { self . session } " 166 )as response : 167hooks = json .load (response )["items" ] 168self .assertIn ( 169self .hook ["id" ], 170 [item ["configuration_resource" ]["value" ]["id" ]for item in hooks ], 171 ) 172 173def test_ingress_framing_refusals_do_not_admit_a_delivery (self ): 174path = self .delivery_path 175signature = ( 176"sha256=" 177+ hmac .new (self .secret .encode (),self .payload ,hashlib .sha256 ).hexdigest () 178 ) 179for framing ,expected_status ,code in ( 180 ({"Transfer-Encoding" :"identity" },400 ,"unsupported_transfer_encoding" ), 181 ({"Content-Length" :"invalid" },400 ,"invalid_request" ), 182 ({"Content-Length" :"65537" },413 ,"request_body_too_large" ), 183 ): 184with self .subTest (framing = framing ): 185status ,headers ,body = refused_request ( 186self .app , 187path , 188 {** framing ,"X-Albedo-Signature" :signature }, 189method = "POST" , 190 ) 191self .assertEqual (status ,expected_status ) 192self .assertEqual (json .loads (body )["code" ],code ) 193self .assertEqual (json .loads (body )["status" ],expected_status ) 194status ,_ ,_ = refused_request ( 195self .app , 196path , 197 {** framing ,"Origin" :"https://attacker.example" }, 198method = "POST" , 199 ) 200self .assertEqual (status ,403 ) 201self .assertFalse (self .provider .requests ) 202self .assertEqual (self .delivery_count (),0 ) 203with self .send ()as response : 204self .assertEqual (response .status ,202 ) 205 206def test_signed_delayed_body_and_unknown_bodies_keep_connection_usable (self ): 207signature = ( 208"sha256=" 209+ hmac .new (self .secret .encode (),self .payload ,hashlib .sha256 ).hexdigest () 210 ) 211self .assertEqual ( 212delayed_request ( 213self .app , 214"POST" , 215self .delivery_path , 216self .payload , 217 { 218"X-Albedo-Signature" :signature , 219"X-Albedo-Event-Id" :"split-delivery" , 220 }, 221 ), 222 (202 ,200 ), 223 ) 224for method ,path in ( 225 ("GET" ,self .delivery_path + "/missing" ), 226 ("POST" ,self .delivery_path + "/missing" ), 227 ): 228with self .subTest (method = method ,path = path ): 229status ,headers ,_ = refused_request ( 230self .app ,path , {"Content-Length" :"2" },method = method 231 ) 232self .assertEqual (status ,401 ) 233self .assertEqual (headers ["www-authenticate" ],"Bearer" ) 234self .assertEqual ( 235delayed_request ( 236self .app , 237method , 238path , 239b"{}" , 240 {"Authorization" :"Bearer " + self .app .connection ["token" ]}, 241 ), 242 (404 ,200 ), 243 ) 244 245def test_agent_binding_reads_hook_and_delivery (self ): 246with self .send ()as response : 247self .delivery = json .load (response )["delivery_id" ] 248deadline = time .monotonic ()+ 40 249while time .monotonic ()< deadline and not self .provider .requests : 250time .sleep (0.25 ) 251self .assertTrue (self .provider .requests ) 252self .app .idle (self .session ,timeout = 40 ) 253self .allow_agent () 254self .app .prompt (self .session ,"probe webhook binding" ).close () 255self .app .idle (self .session ) 256requests = [entry ["request" ]for entry in self .provider .requests ] 257self .assertEqual (len (requests ),3 ) 258outputs = [ 259item .get ("output" ,"" ) 260for item in requests [- 1 ]["input" ] 261if item .get ("type" )== "function_call_output" 262 ] 263self .assertTrue ( 264any ("WEBHOOK_BINDING_OK" in str (output )for output in outputs ),outputs 265 )