Merge current dev and refresh PR #6739 from the 7011 integration

This commit is contained in:
pewdiepie-archdaemon
2026-10-26 16:21:17 +00:00
157 changed files with 8685 additions and 1089 deletions
+45 -45
View File
@@ -1,10 +1,10 @@
{
"digest": "9868d50a542b7ad1",
"digest": "f7e60e4a567c46dc",
"elements": {
"app-shell": {
"app-loader": "f04ad5bf6312d53f",
"attach-strip": "a6474951e2481214",
"body": "58df17f0afa190b9",
"body": "4956f449e6a30e93",
"chat-container": "b00a7c6be4e107b1",
"chat-context-pill": "dd905b87245a9a71",
"chat-history": "e65bdc51f4d1675f",
@@ -23,8 +23,8 @@
"input-icon-btn": "3d1c773acd84a825",
"memory-close-btn": "c3f4ba99098a6e4b",
"memory-list": "da9a37a3de3c1a31",
"memory-modal": "5efc81537eab4526",
"memory-modal-content": "3cadc8581361436e",
"memory-modal": "e2f97b123dc9bc55",
"memory-modal-content": "5c5fe055c711e642",
"memory-modal-header": "a8c822fe8c6d20e7",
"memory-search-input": "8b0fbb33401750d4",
"memory-toolbar-btn": "9f985efa7c896f90",
@@ -42,7 +42,7 @@
"rail-new-chat": "f8f1c2f6be426ab9",
"reasoning-effort-btn": "2a07cdd8ec2b4d26",
"rename-session-modal": "9345624780009d14",
"root": "820190c144fda419",
"root": "a9f7ace2c1cefb24",
"save-custom-preset": "443da70ef5fe9b88",
"scroll-bottom-btn": "24602d72e2949991",
"search-input": "53b94f3e5fdb384d",
@@ -54,7 +54,7 @@
"settings-btn-sm": "c31bcb9bdfbae10d",
"settings-fallbacks": "e339280590935fe3",
"settings-logs-tab": "e470140373597143",
"settings-modal": "5efc81537eab4526",
"settings-modal": "e2f97b123dc9bc55",
"settings-nav-search": "f4484b2980f2ccc5",
"settings-row": "de568724268cec6e",
"settings-select": "a96e8430b0ec6668",
@@ -69,7 +69,7 @@
"theme-grid": "4d4b2aeb8952cc6f",
"theme-io-btn": "859f222c79a8c721",
"theme-modal": "0a65ca916656cdbb",
"theme-popup": "3aa65e6f313bbc65",
"theme-popup": "fb86cc5f81d0aaa6",
"theme-tabs": "0cc4987f39876dce",
"toast": "c60c6a60d4982005",
"tool-indicator": "0dee45eaca8b703a",
@@ -83,7 +83,7 @@
"#agent-indicator": "09a1b8d94b1487ee",
"#agent-indicator.active": "67e2ad3fa48acd3b",
"#compare-model-overlay .modal-body": "0b1ee357313551b9",
"#compare-model-overlay .modal-content": "56883147d181bd9c",
"#compare-model-overlay .modal-content": "9b2145292f439a88",
"#cookbook-modal .cookbook-body": "20bd8a7268cee788",
"#cookbook-modal .hwfit-cached-list.cookbook-serve-models-just-loaded > .doclib-card": "48f6787a3df3f19e",
"#cookbook-modal .modal-content": "0c4e1e686df0990e",
@@ -101,7 +101,7 @@
"#email-lib-modal .email-lib-sync-status": "6d1a0374a16cf7f4",
"#email-lib-modal .email-reader-atts": "c5d68ecda3c14bb8",
"#email-lib-modal .email-reader-header": "85afca11c8cbd38e",
"#email-lib-modal .modal-content": "d8b8fff38c613c95",
"#email-lib-modal .modal-content": "262753f287410717",
"#email-lib-settings-btn": "9ae15bf04b6a22ce",
"#email-unread-dot.sidebar-notif-dot": "c80f1e03592983b6",
"#export-compact-btn": "7d0325a38e51134b",
@@ -686,44 +686,44 @@
},
"variants": {
"app-shell": {
"desktop-dark-comfortable": "65b23a35b5a34126",
"desktop-dark-compact": "1bac3a0aa4467555",
"desktop-dark-spacious": "944583b1854fecbb",
"desktop-light-comfortable": "d279432f8c120b58",
"desktop-light-compact": "aa768c34ba3c7abc",
"desktop-light-spacious": "b74f53e95bfa1d82",
"laptop-dark-comfortable": "d98d16f3d60ed275",
"laptop-dark-compact": "c3eaf3c4278a0120",
"laptop-dark-spacious": "a085fe0f7d9eb66c",
"laptop-light-comfortable": "0d7c64f324bebe87",
"laptop-light-compact": "da50bf03b0f1be58",
"laptop-light-spacious": "a6789fd05a66bf5a",
"phone-dark-comfortable": "21b94f88da549eb5",
"phone-dark-compact": "b9aa7def2b85414e",
"phone-dark-spacious": "21b94f88da549eb5",
"phone-light-comfortable": "4a0cf45db775ea7d",
"phone-light-compact": "f7fbcb94d99c8730",
"phone-light-spacious": "4a0cf45db775ea7d",
"tablet-dark-comfortable": "e18256ce58398f11",
"tablet-dark-compact": "3cf3e52a10313d44",
"tablet-dark-spacious": "e18256ce58398f11",
"tablet-light-comfortable": "c2a45155c391f26c",
"tablet-light-compact": "9fa7dc03dd02b723",
"tablet-light-spacious": "c2a45155c391f26c"
"desktop-dark-comfortable": "0c9123117135e362",
"desktop-dark-compact": "c694f5d5118ac0e2",
"desktop-dark-spacious": "90a67613e8cfdf5d",
"desktop-light-comfortable": "17727a9945ccc77b",
"desktop-light-compact": "2a887c30d231e357",
"desktop-light-spacious": "94b4666734b87625",
"laptop-dark-comfortable": "26d334b139b653f2",
"laptop-dark-compact": "fea30bad6824357d",
"laptop-dark-spacious": "4fdb28deaaaec870",
"laptop-light-comfortable": "c7fc986a7e70aac8",
"laptop-light-compact": "147645431229ddff",
"laptop-light-spacious": "d513c285d30c8289",
"phone-dark-comfortable": "551761344bec8d09",
"phone-dark-compact": "293d66518c3ec994",
"phone-dark-spacious": "551761344bec8d09",
"phone-light-comfortable": "cb41acd3f9af3578",
"phone-light-compact": "dbef6e9e373e9c10",
"phone-light-spacious": "cb41acd3f9af3578",
"tablet-dark-comfortable": "0f682621b4ead393",
"tablet-dark-compact": "e6f3fece5e716a2c",
"tablet-dark-spacious": "0f682621b4ead393",
"tablet-light-comfortable": "6e398df9ca69c58b",
"tablet-light-compact": "dca75981c8675bfa",
"tablet-light-spacious": "6e398df9ca69c58b"
},
"bench": {
"desktop-dark-comfortable": "f9fa9d54db5ad45a",
"desktop-dark-compact": "7603a804cb2e23a2",
"desktop-dark-spacious": "b0c69306349fc078",
"desktop-light-comfortable": "d3e98c4c1e90686f",
"desktop-light-compact": "a04ae70088bc8b35",
"desktop-light-spacious": "4aa01a125f541429",
"laptop-dark-comfortable": "025e60ebe84e0f98",
"laptop-dark-compact": "53d6555bb7a6b5bb",
"laptop-dark-spacious": "c124d9ce1633d027",
"laptop-light-comfortable": "e8328c45f5127bb0",
"laptop-light-compact": "dc2da3534534c888",
"laptop-light-spacious": "abe5483724c987e0",
"desktop-dark-comfortable": "a9709cb47eec7544",
"desktop-dark-compact": "bfab96cb72fd6faf",
"desktop-dark-spacious": "25469178b443428a",
"desktop-light-comfortable": "2166794d643fae4c",
"desktop-light-compact": "81d8ab64407b1d9a",
"desktop-light-spacious": "d6d19f33476665af",
"laptop-dark-comfortable": "57169e64e1fb5324",
"laptop-dark-compact": "6ac903cd8a711bcf",
"laptop-dark-spacious": "a47535332e9c5de7",
"laptop-light-comfortable": "1e2b381de3556fac",
"laptop-light-compact": "6dbfb465121949c1",
"laptop-light-spacious": "1b8bfcd4bc4ed214",
"phone-dark-comfortable": "67ca3c78e6b9495a",
"phone-dark-compact": "ad6f3a9f5c8d81cd",
"phone-dark-spacious": "1e6f66e56519ba5d",
+17
View File
@@ -0,0 +1,17 @@
# CA trust regression fixtures
The public certificates embedded in `tests/test_ca_trust.py` exercise the October 2026 eMudhra trust-store check. They contain no private keys. `emsign-g3.pem` and `aaa.pem` are the incident's affected trust anchors; `emsign-g1.pem` is an unaffected emSign anchor that must remain usable. `localhost-leaf.pem` is a synthetic self-signed Ed25519 server certificate with `CA:FALSE`, generated solely to reproduce hashed non-CA certificates found in Ubuntu's OpenSSL directory; its private key was discarded. The test creates temporary PEM files from these public constants. The certificate fingerprints are the factual identity of each fixture.
Sources: the [incident root list](https://emudhra.we-have-a-problem.com/roots.json) and the public Mozilla root certificates distributed in Debian's `ca-certificates` package.
`incident.json` contains the four published roots, sixteen [compromised intermediate certificates and cross-signs](https://emudhra.we-have-a-problem.com/impacted-intermediates.json), and the [fourteen published server chains](https://emudhra.we-have-a-problem.com/chains.zip), retrieved on 2026-10-21 with chains refreshed on 2026-10-23. The refresh adds the two DV G3B hosts without Certificate Transparency entries and preserves the original twelve chains. Certificates are deduplicated by SHA-256 of DER; each chain lists its leaf first. The additional cross-signed `emSign Root TLS CA - G3` issuer comes from those chains and has fingerprint `3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd`. Like the intermediates, it must not be accepted as a custom trust anchor. All data is public certificate material, with no private keys.
Downloaded source SHA-256 values:
- `roots.json`: `119f32cd81a6b5a4e3c4c2df5fe07daa90dc43cafa123a62f0394f1895d92380`
- `impacted-intermediates.json`: `f67b3abe9b4879f4e91874ed041beccd71257f1db41c588c724ccf12aa90c59e`
- `chains.zip`: `7b949956955fc501a30f4d98d2f513d34c0bbbe92e29a968194c96e097ce2345`
The offline OpenSSL regression verifies server purpose, hostname and strict certificate rules with partial-chain trust, at `2026-10-23T12:00:00Z` (`1792756800`). Fixing the verification date keeps later certificate expiry from masking a broken mitigation. All fourteen chains must verify against the affected positive-control roots and fail against the unaffected G1-only store. Adding the compromised DV G3B intermediate restores four paths, including the two new hosts, which the checker must reject. The regression makes no network requests and skips chain verification if the OpenSSL executable is unavailable; certificate-inventory tests still run using Python's standard library.
The upstream SearXNG image validation uses the same `incident.json` inventory and fixed verification timestamp, checking each stored DER fingerprint before use. See [the image documentation](../../../docs/searxng-trust-image.md) for its validation targets and source provenance.
+132
View File
@@ -0,0 +1,132 @@
{
"verification_time": 1792756800,
"roots": {
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "emSign Root TLS CA - G3",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "emSign Root CS CA - G3",
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "AAA Certificate Services",
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "emSign ECC Root CA - G3"
},
"intermediates": {
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "EM DV TLS CA - G3B (eMudhra)",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "EM DV TLS CA - G3B (Sectigo)",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "EM OV TLS CA - G3B (eMudhra)",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "EM OV TLS CA - G3B (Sectigo)",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "EM EV TLS CA - G3B (eMudhra)",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "EM EV TLS CA - G3B (Sectigo)",
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "EM DV TLS CA - G3C (eMudhra)",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "EM DV TLS CA - G3C (Sectigo)",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "EM OV TLS CA - G3C (eMudhra)",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "EM OV TLS CA - G3C (Sectigo)",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "EM EV TLS CA - G3C (eMudhra)",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "EM EV TLS CA - G3C (Sectigo)",
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "EM OV CS CA - G3B (eMudhra)",
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "EM OV CS CA - G3B (Sectigo)",
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "EM OV CS CA - G3C (eMudhra)",
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "EM OV CS CA - G3C (Sectigo)"
},
"certificates": {
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPWIsuPCu4zaE+GhIJJUMhMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM1\nMDlaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFy\npDPjYw8CvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpV\nbMyLpQVh3xsqRB0Bo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMEN2HWMocbuWdy1d33pH6JpMvP/V/DIS5PtZbv1Q7TXEbxrcyxg\nyuddOJscYDJJlAIwB4sH1BpbScDzkdD4tJBSwrG/1Mx82vb8YaySehNuu1+1oXNd\nAEKwaJ0mVzScy2+6\n-----END CERTIFICATE-----\n",
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcigAwIBAgIUG01Z/jOotSvB49K4K27kFjpdnWMwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMjU3MzFaFw0yNzA1MDUxMzAyMzFa\nMDQxMjAwBgNVBAMMKWR2LWczYi1ub2N0LWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEI805yWr+RxanILifET+/\nGwHsXo7dp8ghA3OgwR0Mg11Bl6iYyOzldy4iU7kM+3qfDNsUrzxvkUkJZQpYieuC\n+aOBrDCBqTAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATA0BgNVHREELTArgilkdi1nM2Itbm9jdC1lbXVkaHJhLndlLWhh\ndmUtYS1wcm9ibGVtLmNvbTAdBgNVHQ4EFgQUOlRrLQGIczrmr4H+Oak06aUmJ4Yw\nHwYDVR0jBBgwFoAU4h4v5xwZOQ5VS2LBICRXNSSKJRQwCgYIKoZIzj0EAwIDZwAw\nZAIwf4ekmRgXAxXP2AjpVY2f1bO42GC7Nnc80t4OpaWJeVdXpT+X3dOsLRwlIUm0\nYbMwAjBihTx7oHfaYB3KRnFuhKqlHYmUcOe9Hjxqs6bubZohhrqMQzPocyasDhJL\nUx3HsgQ=\n-----END CERTIFICATE-----\n",
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0": "-----BEGIN CERTIFICATE-----\nMIICPzCCAcagAwIBAgIUH0gWfBRnOiSjcduti/cukjDGAjIwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDJaFw0yNzA1MDMwMjI3NDJa\nMDMxMTAvBgNVBAMMKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQ3EfjVE+6fUm9gr7UoUv6N\nYPttIdILR9OMZnAdTj+RWT1Z4fp7dpC5mat1gbIo4J6EDelcOQQz5/oGeB3gFJKw\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKG92LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFKzQPp6gTBOWb0TBxZbkL5zsQn3oMB8G\nA1UdIwQYMBaAFJvYZVGv5+csN60I7mkZ2b83hK4KMAoGCCqGSM49BAMCA2cAMGQC\nMARKrRRBEWY2Ix1d80DGUvC7bWENP1Q9pf6BOGF4+84UiwV5+nAuht/+hdpAanWB\nsQIwJF2dW8tpPN6lTm1kDerzG6yJAJOz4drotaTthMF2+efuv5W2FL7Dr8qZSd1z\n201o\n-----END CERTIFICATE-----\n",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "-----BEGIN CERTIFICATE-----\nMIIDdDCCAvmgAwIBAgIQAJYaPvsTWVdu0EAFtGhKWzAKBggqhkjOPQQDAzBrMQsw\nCQYDVQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhy\nYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290\nIENBIC0gRzMwHhcNMjQwNzExMTU0MzM2WhcNMzkwNzA5MTgzMDAwWjBWMQswCQYD\nVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEg\nMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUr\ngQQAIgNiAARfD9w/q1cWxL6D9bMGH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8m\nWO/RwXQy10DCSxT8vSIJClHKp+Ao4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFw\nLKYf8EdDXAyjggF1MIIBcTAfBgNVHSMEGDAWgBR8XQKEE9TMipuBzhccLikenEhj\nQjAdBgNVHQ4EFgQU4TqrIXzx401UrtRyt/2LWUIgkFQwHQYDVR0lBBYwFAYIKwYB\nBQUHAwEGCCsGAQUFBwMCMD0GA1UdIAQ2MDQwMgYEVR0gADAqMCgGCCsGAQUFBwIB\nFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMHgGCCsGAQUFBwEBBGwwajAi\nBggrBgEFBQcwAYYWaHR0cDovL29jc3AuZW1zaWduLmNvbTBEBggrBgEFBQcwAoY4\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbS9jZXJ0cy9lbVNpZ25FQ0NSb290\nQ0FHMy5jcnQwMwYDVR0fBCwwKjAooCagJIYiaHR0cDovL2NybC5lbXNpZ24uY29t\nP1Jvb3RDQUczLmNybDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIB\nATAKBggqhkjOPQQDAwNpADBmAjEAt9CIpRGp46QHQYDUBnLKw+vvK0/h9vmilEAg\nTroUl/anH4x2rXHqiCtBTBfgKDegAjEA8WpG1LTZdAIL0FSYqxXeREaeTSxEofM0\n1jJH3jGvCXC79LU9UPJ2o3TU+UIj2gMV\n-----END CERTIFICATE-----\n",
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5": "-----BEGIN CERTIFICATE-----\nMIICODCCAb+gAwIBAgIUbWvtwSNK/Qro223vAqyz323+VBAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM2WhcNMjcwNTAzMDEwNDM2\nWjAvMS0wKwYDVQQDDCRkdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAScdBQpNUOOCcag6YEVFccNRSFe\nUIl02iH53vDfoWPJFS005WzqF3CPDclB/kygP2gWF4pg/r5gPYdnI3/6Vdzro4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGR2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUBj2yGO/TzDOE/u/6QeUvcW8y1iMwHwYDVR0jBBgw\nFoAUrn4QXDwplsGoRycFWfHgPdTlCvMwCgYIKoZIzj0EAwIDZwAwZAIwBQOOQqG9\nccdzM/7XIlXLtota1esALaLmgEN/ZweosBIvk4xabHcERZInWVIwO45NAjBos8Ue\nNwDe1X3ahdK2lkxqBEoBMtUHJN18TnJKs2nK1gmXpMbmY5oKnAQQgCEZPA4=\n-----END CERTIFICATE-----\n",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAKSu5njyM2K0wORgt3LyFDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTU1\nNDA4WhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAE557ZwEqXc8RzXlERHahawr1eZKs3S4DhLLwgOOweTw47gS/J\nhZ4eZ6+wTi+LyX90hbplqpEe6KTkW2LVzAPVa+Z4s8OMtNyTfKJGDiOEU8iSk10p\n1E+R4VYGtjzO2rRoo4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFOIeL+ccGTkOVUtiwSAkVzUkiiUUMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAmQwCAYGZ4EMAQIBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYi5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRFZUTFNDQUczQi5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwLmsGXi6oFMJ2klkcEMXMug6gvxQWZWkewiDWVzQyl2L6z6DuQfPO\nIbh1grvdULDCAjEAxZvLlxUavG69J6wnSjC8XBbqNIQsJ6CE2QdNOe84Ptxb6VG8\ngt2TlfJbHii+KNvK\n-----END CERTIFICATE-----\n",
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUXcJ4+VeNEICJ9JbBANtIyfP96EowCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRvdi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATLEvRHdoPc0Z9ovgqlYmoKTHPU\nApwd6lAp0ou1G63SzuESyBzIW6Tl64CsKzlV9ggkZjX+L9/tOx97/1wmSTYvo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJG92LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUrytBb+px+b58Df+d5vP8GpTW+/wwHwYDVR0jBBgw\nFoAUNOrqDOvJCtr50MXi7Zt4yYeeG2wwCgYIKoZIzj0EAwIDaAAwZQIwNdoOUOKz\nMLV1OFEoMjBeUqvkQAwsajvVWu0A23qyyvYlosyMp8aFv0gxURW6Yj6DAjEAhe0z\nkOQmmqb0eyhjh7weF4fHtwMtLi4jNFA6M/EfPTfkqE91MN9fSin5uhua5xhk\n-----END CERTIFICATE-----\n",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIQAIAJkopkEHYBrwX6LxnxCTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTUz\nOTMxWhcNNDAwNDI4MTgzMDAwWjBAMQswCQYDVQQGEwJVUzEUMBIGA1UECgwLZU11\nZGhyYSBJbmMxGzAZBgNVBAMMEkVNIEVWIFRMUyBDQSAtIEczQzB2MBAGByqGSM49\nAgEGBSuBBAAiA2IABC1W6Cmm+4LGSkaX2WCvlR6QZ66R+SbTQwbaSKiq63hptqZ+\nbSdCbgRCqgOzbhmI3/Y2UU7hzwf1uks/8qBmRa59MdWGatMao9JG6wsxd+6Zy3Vm\nxRbOdICKZo+R8MR/yqOCAZUwggGRMB8GA1UdIwQYMBaAFOE6qyF88eNNVK7Ucrf9\ni1lCIJBUMB0GA1UdDgQWBBSmJV6AqU2385Z8G8eR35K87kvrXzAdBgNVHSUEFjAU\nBggrBgEFBQcDAQYIKwYBBQUHAwIwXAYDVR0gBFUwUzA5BgsrBgEEAYOOIQEAATAq\nMCgGCCsGAQUFBwIBFhxodHRwOi8vcmVwb3NpdG9yeS5lbXNpZ24uY29tMA0GCysG\nAQQBg44hAQJ4MAcGBWeBDAEBMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNRVZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIwBy1wTnzH4lXDTy2PD91ysLjvnlVN3JbJmc1BIMEU8TOLkYVHibMc\n9HeyonikFSfmAjEA6uE3QRxlrZTz/1/lXPTEMUhoauAtycagtlNFp79kjhvNa2/f\nAal1GNxsOutLnr0Y\n-----END CERTIFICATE-----\n",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAKAk/fS6Zlx4XOSgIEg4WLcwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBEViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAATnntnASpdzxHNeUREdqFrCvV5kqzdLgOEsvCA47B5PDjuBL8mFnh5nr7BO\nL4vJf3SFumWqkR7opORbYtXMA9Vr5nizw4y03JN8okYOI4RTyJKTXSnUT5HhVga2\nPM7atGijggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU4h4v5xwZOQ5VS2LBICRXNSSKJRQwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQIBMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJkMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQBzQ1IoqpdotM6gFrR4Q3YrKifOLeaj5XXunldpVE/vZn2TxC2y0s8oYiLR5ICE\nwqVXSB7N6BM1Mp+Nif7Jbhb2IswAfSR4Iik8+B0iRmi7lYoNlycCgHjmsjOmZaxM\nQkPmjXAJbG5f4V41OsvJZmS7lI3r0IuvEDVX3GAZ13on6IImSs9ybNpHQ7NCqkMQ\n7wH4uAO1dtpFycgJSdHaCOhHjrox2BTYGLdpUrYwY3EOAjlZ8UGgsxs7mG+xquMl\nbN1LFURwfnkVMP3T/DBIZ/ys2+lNFyxK3FlArrV4/000uTnVB1lO8oGORYdXoT5V\nGUyRCEIDhF5TBUuKF4E3kf1h\n-----END CERTIFICATE-----\n",
"4b2460a466f95fd40113bcdcca2ceb29b3b093921de45474da7df48874c630b1": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuGgAwIBAgIPVEt/kiwxriIKSpIpFvbmMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2Mzg0\nMFoXDTQwMDQyODE4MzAwMFowPzELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRo\ncmEgSW5jMRowGAYDVQQDDBFFTSBPViBDUyBDQSAtIEczQzB2MBAGByqGSM49AgEG\nBSuBBAAiA2IABLnw4GNHAqacpqrdRDz9YsQbm6SyCSRpfXl052kaEm+5avs+NML9\nO8X0gwYfJl3CpYQ2NHMjUQQ2so8E84YjYwZ5gOJQPgJTLWVGI1pxZaJZoe9Jb3Qb\nX97neHII5Lj676OCAYswggGHMB8GA1UdIwQYMBaAFGh0A/iFyo1UUFcdFuDI3kUN\nq80tMB0GA1UdDgQWBBTub3MpjW+/p7Mbw5U09hVWRHhmtzATBgNVHSUEDDAKBggr\nBgEFBQcDAzBeBgNVHSAEVzBVMDkGCysGAQQBg44hAQABMCowKAYIKwYBBQUHAgEW\nHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDgYMKwYBBAGDjiEBAoFIMAgG\nBmeBDAEEATB0BggrBgEFBQcBAQRoMGYwJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3Nw\nLWMuZW1zaWduLmNvbTA+BggrBgEFBQcwAoYyaHR0cDovL3JlcG9zaXRvcnkuZW1z\naWduLmNvbS9jZXJ0cy9FTU9WQ1NDQUczQy5wN2MwNgYDVR0fBC8wLTAroCmgJ4Yl\naHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290Y3NjYWczLmNybDAOBgNVHQ8BAf8E\nBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQDAwNoADBlAjEA8yPg\n8sntOhLwV2QXJ5LEaKSpo/fnE3uOgTadK73fDhyjiqr0Io9GVuQTYqBPbQ2TAjBg\neuApmBS486+iei03lRrp139dPw6k28jjGNvWsAU7A1N86J1zPJVDEyVH+nMAWC0=\n-----END CERTIFICATE-----\n",
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db": "-----BEGIN CERTIFICATE-----\nMIICSTCCAdCgAwIBAgIUO7oRaIOhMWD0jFgvxusAGNi5CI0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjIxMzE0NDhaFw0yNzA1MDUxMzE5NDha\nMDgxNjA0BgNVBAMMLWR2LWczYi1hYWEtbm9jdC1lbXVkaHJhLndlLWhhdmUtYS1w\ncm9ibGVtLmNvbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABJ0yLMf28vw2E6pO\nc74doinU47pwTYD6xZ60nkrGkqI05DxVKXblos+V5S0+bgDjT2qw1F39EW9EUj8b\nNQmmqv6jgbAwga0wDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0l\nBAwwCgYIKwYBBQUHAwEwOAYDVR0RBDEwL4ItZHYtZzNiLWFhYS1ub2N0LWVtdWRo\ncmEud2UtaGF2ZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBTruPPaZ1PiEFfpqdLo\n8Ra5Ah5a2jAfBgNVHSMEGDAWgBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjO\nPQQDAgNnADBkAjBUTUTymJylZiZRV/RqMayZdUPJKRDIcn9+sUPpG66AjVgx8M+h\nDGzxxgeroOSxzSwCMBM/r1y7g371RI10nDU16T0WEAO/Udqyyuc8mJ2LCQ+Gxv3c\njU2NXGQLsvosv6T0ww==\n-----END CERTIFICATE-----\n",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIRANj4aF5PK9nYJAKoWhwetzEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQl26haZNOX2gsQUunqVG5Ie+/qnJ2daKDF6tz+73Wmm7XW/AH0K/vKbrQL\nzWTAdn42/9Bdl6gTxaoMbJUw55z18IZkaPE4BkIKpchRhAseJSVA55DktlTDhS7W\nSvHoZX2jggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUPtwg2YlGmId7hz/YgUeyXBXYRwYwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAJNFuAFW9Gnw/8+kCO4KIlZdnM1Sh2u+8cK0EYAHhXpb5FlGvYkcARigo5oS7VoM\n7FOYlTueV6yICZKNAYawZPJPhNCGK+rp3cQBEz6d0n8OZEYZov9lb3ll8ORgSDuf\nFVIW6Gagg/UnOamLBTOR8eD5V73SCme/B2cHUDw+m5E8cc+0Ujzz3Cn3ex5JrhXk\n2T3s8PoVzEmhTgGboFfcWO1EYhPPrYwdxPYRQIXAXUXhLEOVNL9ASbZJLFI8E1IM\nm7NwxVN2s38jWpaCUaaF0tg7hfiy7oe13rzJfHsdqldJVTTUTPWVx8TC4ju0VLet\nbAlwhwFT6AP8uIVMGUHNw24=\n-----END CERTIFICATE-----\n",
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906": "-----BEGIN CERTIFICATE-----\nMIICQTCCAcegAwIBAgIULuWnWW6kFV6QZxp2ls8r18NCjXkwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBPViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEb7+5g2YHNYwH2eQERrZc\nTmVuf/RmvzsGGMSwXmZke6vn35Ut5HME7UOOU3Muy+BSfjfpo6fjL5KOs3Ndz67d\nOKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihvdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBQ7oBzq3jpTuvntbIkzMV12Ps3O0TAf\nBgNVHSMEGDAWgBQ06uoM68kK2vnQxeLtm3jJh54bbDAKBggqhkjOPQQDAgNoADBl\nAjEA/Y7AZ4M2MYbN5Od9tREaUfFjZKoOjczfIKPjKNMjtGxTdL9WUXvAT4QDgktn\n/IJdAjAbORTkAXD5hggyG6K7p0MBz3lFZJb2l/LndZlrttJWBLP8QGrc44hrZZtK\nIq0d1dE=\n-----END CERTIFICATE-----\n",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699": "-----BEGIN CERTIFICATE-----\nMIIDzjCCAragAwIBAgIRAOlCI73pL5FOWrmsq/2sNxAwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVkaHJhIEJW\nMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK56Ks1xwy\nKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDftDIVOTMEv\nTPhkbQ2jggE1MIIBMTAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDEGA1UdIAQqMCgwCAYGZ4EMAQICMA0GCysGAQQBg44hAQABMA0GCysGAQQBg44h\nAQJuMEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FB\nQUNlcnRpZmljYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEF\nBQcwAYYYaHR0cDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IB\nAQALRoOSvkzRa+tN5Rje9W5GiXd/qsicd9N6Gnh0SWC7VyWH+HycIqAnFCMYZznj\nCeGusomFYHE42SHWr85JDIbgzy6y3dO4i9n9w0Bx7yrXWBbDzFS8VgA3+EY4Cg7y\nmtoC3RPOJVtaXXPZ+68X804g1Mj+3vCSHj2lNPVgco0OzSIOJ/KMg1R9MLBkiKzb\nbERAGZHejY0HBhYd76IINZN8z6bBNah2UdFYxsOisLynPQUkol6gRLum024/FzVl\nCCDfnUusrkr+mAfPAvD/Vb8bhpA7jYtepuU+2mIdShj5lLlvAwZphn+ypWycuo2x\n0k9mldz2dqI9nhCiY+TTIOOR\n-----END CERTIFICATE-----\n",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAJHzR/Glt9OZYrleDuTimdEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gRFYgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEOnp4YsjETTJN3WkDOmeCwO/u/kBV5ooSP6A3G6O9kZRRTuFypDPjYw8C\nvRiAN9nlFJ+bd9uUooHfmHsv6oiLdL3+j9pMEJnubKGVZv4+8OX2sJpVbMyLpQVh\n3xsqRB0Bo4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFK5+EFw8KZbBqEcnBVnx4D3U5QrzMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECATANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECZDBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAr7b6Mx11TIJgVzHt6EH5qGNWeiiqoMrp8E5/k8s+jvupx1n083pZ5XOzUzND\nwEUe0W4C4FIGUHNQ4XDwqOw0yK42xqm2dvCKgsko2UvDlT0haZVtTCeWMQOe5J/J\nziaMfZwsZNfZ3aaI6XPtWDZtwY8AaNfyaeCpbtm5GcGnyNWk4jSNpbtpo6NjfXuP\nCFOm3rTjJ4zytP7Tso6iMm0L01cYFOlTmoPv4515R2r3SPnKDtijoB/D/vgxzojv\nDafgyG9afX8+laO9NbMMz4sqC1vZx3984i/sBNFiugm3Vvdq5OdJRjuGC1QILIs6\ndg/K3i8VQqRpbfaM9kIlMzZ3dQ==\n-----END CERTIFICATE-----\n",
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed": "-----BEGIN CERTIFICATE-----\nMIICNzCCAb6gAwIBAgIUPfthO0PYGAYKs9lQ4gp1IOkOr2MwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGV2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABPZPwlA3io/AX/pMwu0dNl9WMxrr\nC9j/xMLHgG72cWVzoX62REmvp9zyXQ04kHP9/sW6KnCcm5ZZ5i4YoJbDPASjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZXYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBSvX4QR4ERW5uNUaMq0yYzXCZf0ZjAfBgNVHSMEGDAW\ngBQ+3CDZiUaYh3uHP9iBR7JcFdhHBjAKBggqhkjOPQQDAgNnADBkAjACy/uJR01M\n/O9IEZyRHX1LSU28bmg542K0IX9jDRTrZXUqkDWfiZIL7xfnrRrBpHoCMFbBIaOa\nWwWtFtDg9ZqQvktsWS950u5qfW1sChgWNya/E3IMKFgtoHm2xrZsmfwLSQ==\n-----END CERTIFICATE-----\n",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489": "-----BEGIN CERTIFICATE-----\nMIIDzzCCAregAwIBAgIRAI1aSBGTnvRH13ERzdmEmukwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0CAQYFK4EE\nACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZfjpRIOHv\n1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+N7ulaVSf\nHAKycO1no4IBNTCCATEwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQw\nHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMA4GA1UdDwEB/wQEAwIBBjAS\nBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdJQQWMBQGCCsGAQUFBwMBBggrBgEFBQcD\nAjAxBgNVHSAEKjAoMAgGBmeBDAECAjANBgsrBgEEAYOOIQEAATANBgsrBgEEAYOO\nIQECbjBDBgNVHR8EPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9B\nQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYB\nBQUHMAGGGGh0dHA6Ly9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOC\nAQEAHlbhZpjhkPEAQVhPHRtBDaHqiisVD20pQ1TUeSx3l62Qe7NQ0b7vWQy7DN0i\nKOu7BGpP5EnIjvXbz9FhpfPJ4FhKEt3tQ51rqXz2oD8vRaryvExWPobDUz3j7HN6\nOzBsVMIe3PD7AAkpky0x8zMSi/ep5R9OMzybmNptbtppyuB5ytgq+h7lQdRIP+CQ\nTaJX3qTgfMqzntYre5GljmEB5hU2/rSn8zoSU2APkJidlZFpghtgbD4I2VRHZvCw\nFFr/SrFM9pwrgbJNZ1MtyTSVgI5NlNb0aMfLR+/r6jJt3ROeY2yh2QQ2dx+Yqgdb\nuL5ChFWOQU+ErSDnUq7yr+pjBQ==\n-----END CERTIFICATE-----\n",
"7dd78d5f4f13459a83dff9abbba62edbaf6f2d102bf257fd712f4d9f2746ed8d": "-----BEGIN CERTIFICATE-----\nMIICKTCCAa+gAwIBAgIQDnYGcvFDRZ/I/gqwvAXjlDAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjQwMjA4MDgx\nOTMyWhcNNDkwMjA3MDgxOTMyWjBWMQswCQYDVQQGEwJJTjElMCMGA1UECgwcZU11\nZGhyYSBUZWNobm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAwwXZW1TaWduIFJvb3Qg\nVExTIENBIC0gRzMwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAARfD9w/q1cWxL6D9bMG\nH6CQ4u+gAV6GN9s+PwQx9Bl4hC+0iU8QOk8mWO/RwXQy10DCSxT8vSIJClHKp+Ao\n4qJKdYeV0SwZ7LMQziyLfxIUOY6Grja/sQFwLKYf8EdDXAyjQjBAMB0GA1UdDgQW\nBBThOqshfPHjTVSu1HK3/YtZQiCQVDAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/\nBAUwAwEB/zAKBggqhkjOPQQDAwNoADBlAjBhCFTkly7Yl7+s4hiw+k6ZJTXT2AiI\nrvmsmfqPr5E7ecwXb6kXA5LTFd6rjwTuV3sCMQC9w6ZSbVq1mveMO6dKFziOzClT\nKCT2RFT6yXtq6nzgRICgBmiXbJAXsmZ8yAwjyWc=\n-----END CERTIFICATE-----\n",
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUU3mgjls/8ZJ8upmukdlSQR1IF/UwCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAATK3sfcOxxtM2Vdq9Qbw/GR\nNsHlJcnqINEInXO3fkWhw/FYT80hkqGk0jSN/EKib3YZJsr5GyfkYcpEiA2rwMgm\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGR2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFDggfJFz1eeToKmzFBSYkMCvpAu0MB8G\nA1UdIwQYMBaAFOIeL+ccGTkOVUtiwSAkVzUkiiUUMAoGCCqGSM49BAMCA2gAMGUC\nMDqDDOt7K313QIH0rhn/ZKnzFu8+/9W8OlTuFtDfVCTRDfRmqdyfsCfLsEk7xqHq\nuQIxAJqyLERABmU98U46OCuT5bp5h0ytuSEIGM7orTpKci5S1u1B/AOmwv162U7R\nTcVPHA==\n-----END CERTIFICATE-----\n",
"84112972b8e59d14560ae7e7119654474aad119990cf3722bfc1e3b91615e1fd": "-----BEGIN CERTIFICATE-----\nMIIDxTCCAq2gAwIBAgIRANal72E7OQ8y75zfNJ93KEEwDQYJKoZIhvcNAQELBQAw\nezELMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4G\nA1UEBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNV\nBAMMGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczAeFw0yNTA1MTQwMDAwMDBaFw0y\nODEyMzEyMzU5NTlaMD8xCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVkaHJhIElu\nYzEaMBgGA1UEAwwRRU0gT1YgQ1MgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAS58OBjRwKmnKaq3UQ8/WLEG5uksgkkaX15dOdpGhJvuWr7PjTC/TvF9IMG\nHyZdwqWENjRzI1EENrKPBPOGI2MGeYDiUD4CUy1lRiNacWWiWaHvSW90G1/e53hy\nCOS4+u+jggEsMIIBKDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQU7m9zKY1vv6ezG8OVNPYVVkR4ZrcwDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwEwYDVR0lBAwwCgYIKwYBBQUHAwMwMgYDVR0gBCsw\nKTAIBgZngQwBBAEwDQYLKwYBBAGDjiEBAAEwDgYMKwYBBAGDjiEBAoFIMEMGA1Ud\nHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwuY29tb2RvY2EuY29tL0FBQUNlcnRpZmlj\nYXRlU2VydmljZXMuY3JsMDQGCCsGAQUFBwEBBCgwJjAkBggrBgEFBQcwAYYYaHR0\ncDovL29jc3AuY29tb2RvY2EuY29tMA0GCSqGSIb3DQEBCwUAA4IBAQBgGbZ+NDkn\n2Fr5dCVs/51+6cT5iEPP1stglX4TI2UaNh9LmPdltxXw3crV8gK5L8i+d+ScGibF\ny0obtBHNTldXDgLnAHIzY3n+pceyKj7O2ae9CYnyuu5RBifeI3XYKowWE+oXnVGF\nOy0CvCJ/JsdP6aYgoCa9hGnAYicYQQL6MOMQZ5PuhcSM8t4giIzrpgrEkUP93GYE\nemDG5Z8BYwKs80lLeQNBnwg/VHJ3iEF/o84RXYwrlrKqJ+33C094o6LNikMJCqOK\n/WYHZSR8Xyhf8GXFAj5pZuI6Bcpug2V/E7i42j8OBhc25aNYDqK+B0qmb90sjNUB\ng3tJeSIb/599\n-----END CERTIFICATE-----\n",
"86a1ecba089c4a8d3bbe2734c612ba341d813e043cf9e8a862cd5c57a36bbe6b": "-----BEGIN CERTIFICATE-----\nMIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG\nEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo\nbm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g\nRzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ\nTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s\nb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw\ndjAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0\nWXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS\nfvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB\nzhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq\nhkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB\nCUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD\n+JbNR6iC8hZVdyR+EhCVBCyj\n-----END CERTIFICATE-----\n",
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52": "-----BEGIN CERTIFICATE-----\nMIICQjCCAcegAwIBAgIUMH3z62Z6agEULBZqrfhpw9/ZpCAwCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBEViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQxWhcNMjcwNTAzMDIyNzQx\nWjAzMTEwLwYDVQQDDChkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEAMPpHsQqEqIerrj/durO\nEN7gkUVJSVgIPN6+VJ2sEGXDyqXAN1fnNzGuASP/4WEr0RIhht3cYvuUYp2SYofr\nw6OBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihkdi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBT8CNQz4OLVnIwrQYZBF+++9jTCvzAf\nBgNVHSMEGDAWgBSufhBcPCmWwahHJwVZ8eA91OUK8zAKBggqhkjOPQQDAgNpADBm\nAjEAp3ZLdfBvKcZxgR2p8LZaxWzP6uB7zU/EZ9BA49Ds2P5ByYkRodwK5qcwt4J/\nVL/vAjEAnnxrcrDX7SzoJxuho604GBh6eyBLJLCdmcy+qb+LA3u5oxP/OjAzCA4x\nv1kw6Tm4\n-----END CERTIFICATE-----\n",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b": "-----BEGIN CERTIFICATE-----\nMIIDZjCCAu2gAwIBAgIPbK2jZ/scEACIEg6iAtVoMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNjAw\nMTJaFw00MDA0MjgxODMwMDBaMD8xCzAJBgNVBAYTAk5MMRMwEQYDVQQKDAplTXVk\naHJhIEJWMRswGQYDVQQDDBJFTSBPViBUTFMgQ0EgLSBHM0IwdjAQBgcqhkjOPQIB\nBgUrgQQAIgNiAARziXqg6zz9yosSQC/68KvE4SErr43ud4rJ7i1tuD+sKAxeBWOK\n56Ks1xwyKo42HN659pxDArndjXPRzGyqsswK7k4hbOl53VGKSvS2JuVoTN1lgDft\nDIVOTMEvTPhkbQ2jggGWMIIBkjAfBgNVHSMEGDAWgBThOqshfPHjTVSu1HK3/YtZ\nQiCQVDAdBgNVHQ4EFgQUm9hlUa/n5yw3rQjuaRnZvzeErgowHQYDVR0lBBYwFAYI\nKwYBBQUHAwEGCCsGAQUFBwMCMF0GA1UdIARWMFQwOQYLKwYBBAGDjiEBAAEwKjAo\nBggrBgEFBQcCARYcaHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTANBgsrBgEE\nAYOOIQECbjAIBgZngQwBAgIwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1PVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNnADBkAjBAk+5BizdJhrED3wb7F2Gm7bWI60dlNd075nghT2TrR4NWKZtJZcbc\n28xxIY/NONICMF6mPdsjosaqmvEMWqKcBh4vsIvXSKnQj5o8zrFB4oKS+accjYTe\n8Dz3bQjauvy9uw==\n-----END CERTIFICATE-----\n",
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb6gAwIBAgIUY1R/PHrtXShV0uhLJ5EyjsHvMi0wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIERWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzZaFw0yNzA1MDMwMTA0MzZa\nMC8xLTArBgNVBAMMJGR2LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABFpE24ACBFHQLzpg+7D767thJ3+Q\nNIoJJugUMT/OftEz6G6uDdxlS+9VfXYBDglHjJirjEPFNglOGJoQNQuiz7Gjgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkZHYtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBT4r5v4etYNuG21BqBqs273OkcHiDAfBgNVHSMEGDAW\ngBTiHi/nHBk5DlVLYsEgJFc1JIolFDAKBggqhkjOPQQDAgNpADBmAjEAlxNF+/8l\nAPpgibawACy15yn5bcvTksz2Zw0R9z9nUK9e3MSSk+hXagg7LVb7V4NtAjEA4qyR\nlaFPkaokrnTjqQieq8CgDQ6tIz3FjBvj3D7vkhKXHaml6zD2zT5ih2Y+lD2h\n-----END CERTIFICATE-----\n",
"9e39de2881273acc225a149c72ba3e0f51424963669ab633ee1dc7df5db067b4": "-----BEGIN CERTIFICATE-----\nMIIDWzCCAuCgAwIBAgIPeBiTP4+FtGGaKzX7qD0KMAoGCCqGSM49BAMDMFUxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBDUyBDQSAtIEczMB4XDTI1MDQzMDE2MDgy\nN1oXDTQwMDQyODE4MzAwMFowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRo\ncmEgQlYxGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYF\nK4EEACIDYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUOb\nNKBDHK/+W/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jord\nd7EAZPKr5eBQo4IBizCCAYcwHwYDVR0jBBgwFoAUaHQD+IXKjVRQVx0W4MjeRQ2r\nzS0wHQYDVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMBMGA1UdJQQMMAoGCCsG\nAQUFBwMDMF4GA1UdIARXMFUwOQYLKwYBBAGDjiEBAAEwKjAoBggrBgEFBQcCARYc\naHR0cDovL3JlcG9zaXRvcnkuZW1zaWduLmNvbTAOBgwrBgEEAYOOIQECgUgwCAYG\nZ4EMAQQBMHQGCCsGAQUFBwEBBGgwZjAkBggrBgEFBQcwAYYYaHR0cDovL29jc3At\nYi5lbXNpZ24uY29tMD4GCCsGAQUFBzAChjJodHRwOi8vcmVwb3NpdG9yeS5lbXNp\nZ24uY29tL2NlcnRzL0VNT1ZDU0NBRzNCLnA3YzA2BgNVHR8ELzAtMCugKaAnhiVo\ndHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3Rjc2NhZzMuY3JsMA4GA1UdDwEB/wQE\nAwIBBjASBgNVHRMBAf8ECDAGAQH/AgEAMAoGCCqGSM49BAMDA2kAMGYCMQC/zr03\nYEELsNKqzUtH2dVFi4BaKFdyrr/6G1dIWp8KAVgsw2/u4lyyboUDaC9jDvICMQC9\nHTxku84tNX9dmHOtH7aC2Nu/JM0jTf6KjZAvovCJzzv1IFWSZpVLZibynMLXOrM=\n-----END CERTIFICATE-----\n",
"a496e23ac0cab8155fc9b117b97eae368efcbb9041aec8804d5a308d2d01c2d8": "-----BEGIN CERTIFICATE-----\nMIIDwzCCAqugAwIBAgIQQeMrc6wYXWzg10gLrPhOaTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowPjELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYx\nGjAYBgNVBAMMEUVNIE9WIENTIENBIC0gRzNCMHYwEAYHKoZIzj0CAQYFK4EEACID\nYgAEqrsPsGZqz88B+tUr/P1MK/LLPDSX3nOVDlgZth/mlQmHQcjEkUObNKBDHK/+\nW/m0wcjorgAB+fTcitgsWfNjnpbNbD1NsM7+wmw9YlF7N3YTcnQ/jordd7EAZPKr\n5eBQo4IBLDCCASgwHwYDVR0jBBgwFoAUoBEKIz6W8Qfs4q8p74Klf9AwpLQwHQYD\nVR0OBBYEFHn66OzP2w11jaRY1p+27dx8Sd7mMA4GA1UdDwEB/wQEAwIBBjASBgNV\nHRMBAf8ECDAGAQH/AgEAMBMGA1UdJQQMMAoGCCsGAQUFBwMDMDIGA1UdIAQrMCkw\nCAYGZ4EMAQQBMA0GCysGAQQBg44hAQABMA4GDCsGAQQBg44hAQKBSDBDBgNVHR8E\nPDA6MDigNqA0hjJodHRwOi8vY3JsLmNvbW9kb2NhLmNvbS9BQUFDZXJ0aWZpY2F0\nZVNlcnZpY2VzLmNybDA0BggrBgEFBQcBAQQoMCYwJAYIKwYBBQUHMAGGGGh0dHA6\nLy9vY3NwLmNvbW9kb2NhLmNvbTANBgkqhkiG9w0BAQsFAAOCAQEAIh0OzWsdBYhY\n7+yxPGyosRL+uUSN6/Mrd30fBuougs5FVWQ3cRgLhImnUUgXADY9buugWhMmfT3c\nyU5T1MFXZXClCILm5mfg+oqm/ikUWJl3Ayf2GvYf2O++6vzqvkn418nI5Lpdt0O7\nLi524YS/3CZHDA+zZQ7rrHpZXgQ9Hmp8AUQ4FneaGjNanFoYxiZxwCyW5z4u7AoK\n0bmOVqKtcDFoVNz5++G9TYDJdhWOXmKKgzqRo1MK0PjazNT7MGHqknP93SqMVJ1g\nmX/qNWxYteAjlvFanQxBNX/+TRxDjuFsEHO/7BvqSpIIck0UEEfizhN8zNpWLbcl\nEqVgl0g39A==\n-----END CERTIFICATE-----\n",
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcagAwIBAgIUUYu3O8bpN1GPvCIHqWpT/FYUNDowCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIEVWIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMjIyNDFaFw0yNzA1MDMwMjI3NDFa\nMDMxMTAvBgNVBAMMKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxl\nbS5jb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAASjDLpnVdmvXM6R3UB2o42a\n1GnrYfxRXo7UII36mBl9mIS7p9MkAryhPdxitFHkgTrrbypVtzpydNtJIGGGXUwE\no4GrMIGoMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoG\nCCsGAQUFBwMBMDMGA1UdEQQsMCqCKGV2LWczYi1hYWEtZW11ZGhyYS53ZS1oYXZl\nLWEtcHJvYmxlbS5jb20wHQYDVR0OBBYEFEVBY1ecTN9bLMDl4ifdnN2WZL+WMB8G\nA1UdIwQYMBaAFD7cINmJRpiHe4c/2IFHslwV2EcGMAoGCCqGSM49BAMCA2gAMGUC\nMQCyvr9gktgmtsuTuFleFV+YExNNKLf3GZieuUprQcERrzh798oYKavTqZeYf9Kz\nRhECMCVs1B7KPh7/lgHRly14Z8RJA8ApR7e2cRREW5uPKFOSDlZ/RIezxsfEzsQ4\nlAODeA==\n-----END CERTIFICATE-----\n",
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753": "-----BEGIN CERTIFICATE-----\nMIICQDCCAcegAwIBAgIUTMUrGzSU+0BySFdrN3ALZq9Spv8wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDIyMjQyWhcNMjcwNTAzMDIyNzQy\nWjAzMTEwLwYDVQQDDChldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEglClDbotLFFeY9SpryT4\nk958tvriY2DDnaPsmjQaecRhB6+L/8hLw6FMt+HT8SLvjIZnFY+AH92cBjedcNST\nAKOBqzCBqDAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK\nBggrBgEFBQcDATAzBgNVHREELDAqgihldi1nM2MtYWFhLWVtdWRocmEud2UtaGF2\nZS1hLXByb2JsZW0uY29tMB0GA1UdDgQWBBSVtGbn6xUT1iosUeaGA15Nq640aTAf\nBgNVHSMEGDAWgBSmJV6AqU2385Z8G8eR35K87kvrXzAKBggqhkjOPQQDAgNnADBk\nAjAg7b5bIK9X50QEX0U6G8fQh12BnFQ079Iluzd0Y2TJWCjtVXZi3nOciM8RzNKM\ntZECMCBf0JOyMt8RNS93Wj8kYbWkCtoKoUrqx7G3JTfGReKci+DdTvkF8/3F6doi\n9KB22Q==\n-----END CERTIFICATE-----\n",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu6gAwIBAgIPKWkmwiejoVJjkrGUgqUEMAoGCCqGSM49BAMDMFYxCzAJ\nBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVkaHJhIFRlY2hub2xvZ2llcyBMaW1pdGVk\nMSAwHgYDVQQDDBdlbVNpZ24gUm9vdCBUTFMgQ0EgLSBHMzAeFw0yNTA0MzAxNTM3\nNDFaFw00MDA0MjgxODMwMDBaMEAxCzAJBgNVBAYTAlVTMRQwEgYDVQQKDAtlTXVk\naHJhIEluYzEbMBkGA1UEAwwSRU0gT1YgVExTIENBIC0gRzNDMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEE4JRzVKskpjByKrZdzId65fnCb0LTKCspV3IrVHc/J0ViaFZ\nfjpRIOHv1jHGPhg1ysrimGmmLhYx6KKBgatWYW3EXZGKvAnzqw5jz3kqpNztc4e+\nN7ulaVSfHAKycO1no4IBljCCAZIwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFDTq6gzryQra+dDF4u2beMmHnhtsMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBdBgNVHSAEVjBUMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAm4wCAYGZ4EMAQICMHUGCCsGAQUFBwEBBGkwZzAkBggrBgEFBQcwAYYY\naHR0cDovL29jc3AtYy5lbXNpZ24uY29tMD8GCCsGAQUFBzAChjNodHRwOi8vcmVw\nb3NpdG9yeS5lbXNpZ24uY29tL2NlcnRzL0VNT1ZUTFNDQUczQy5wN2MwNwYDVR0f\nBDAwLjAsoCqgKIYmaHR0cDovL2NybC5lbXNpZ24uY29tLz9yb290dGxzY2FnMy5j\ncmwwDgYDVR0PAQH/BAQDAgEGMBIGA1UdEwEB/wQIMAYBAf8CAQAwCgYIKoZIzj0E\nAwMDaAAwZQIxAMSZt2xsPeumRdeSMSQ3CbzIZeQ/q7GDCWfeE1Pq+mhgA6gRM44c\nNVF3xJ+wsxJk+wIwFJbaDMokD8Ei6eNOiA8yUMYvfzQyngmrhZQMdPCK1SewrR79\n4rSb9vGqxZzelQth\n-----END CERTIFICATE-----\n",
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191": "-----BEGIN CERTIFICATE-----\nMIICOTCCAb+gAwIBAgIUOcw5N4XGLiB/3GvB05bJuGoZPR0wCgYIKoZIzj0EAwIw\nQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5jMRswGQYDVQQDDBJF\nTSBFViBUTFMgQ0EgLSBHM0MwHhcNMjYxMDIwMDA1OTM3WhcNMjcwNTAzMDEwNDM3\nWjAvMS0wKwYDVQQDDCRldi1nM2MtZW11ZGhyYS53ZS1oYXZlLWEtcHJvYmxlbS5j\nb20wWTATBgcqhkjOPQIBBggqhkjOPQMBBwNCAAQo7U02MAUO8EEMomXwIl+kSQzm\nb+nvXKIZ/hC4rZ1GpXchv4SzTrV1SB/Qg+pD6OBEXbBBnPmQNkePNQTlujqRo4Gn\nMIGkMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgeAMBMGA1UdJQQMMAoGCCsG\nAQUFBwMBMC8GA1UdEQQoMCaCJGV2LWczYy1lbXVkaHJhLndlLWhhdmUtYS1wcm9i\nbGVtLmNvbTAdBgNVHQ4EFgQUqxe+7qS+MxNyioJZhQsL5MEEw6swHwYDVR0jBBgw\nFoAUpiVegKlNt/OWfBvHkd+SvO5L618wCgYIKoZIzj0EAwIDaAAwZQIwC/CxSIov\nwcar3sV98B8cL+QuR2kkJdLdXsFKzg/HDSJ1MOjqTGm924LB6r9nl0f7AjEAzcwr\nWQOYY9eycqIz+LG2daYS6LOctj+CjoAk/CLugWPxGxp4Aerfau2P1z1h5jAA\n-----END CERTIFICATE-----\n",
"d7a7a0fb5d7e2731d771e9484ebcdef71d5f0c3e0a2948782bc83ee0ea699ef4": "-----BEGIN CERTIFICATE-----\nMIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb\nMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow\nGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj\nYXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL\nMAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE\nBwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM\nGEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP\nADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua\nBtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe\n3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4\nYgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR\nrOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm\nez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU\noBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v\nQUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t\nb2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF\nAAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q\nGE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz\nRt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2\nG9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi\nl2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3\nsmPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==\n-----END CERTIFICATE-----\n",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838": "-----BEGIN CERTIFICATE-----\nMIIDaDCCAu2gAwIBAgIQAMfEHv6S2CXwXq6EGkqCwTAKBggqhkjOPQQDAzBWMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEgMB4GA1UEAwwXZW1TaWduIFJvb3QgVExTIENBIC0gRzMwHhcNMjUwNDMwMTYw\nMzMxWhcNNDAwNDI4MTgzMDAwWjA/MQswCQYDVQQGEwJOTDETMBEGA1UECgwKZU11\nZGhyYSBCVjEbMBkGA1UEAwwSRU0gRVYgVExTIENBIC0gRzNCMHYwEAYHKoZIzj0C\nAQYFK4EEACIDYgAEJduoWmTTl9oLEFLp6lRuSHvv6pydnWigxerc/u91ppu11vwB\n9Cv7ym60C81kwHZ+Nv/QXZeoE8WqDGyVMOec9fCGZGjxOAZCCqXIUYQLHiUlQOeQ\n5LZUw4Uu1krx6GV9o4IBlTCCAZEwHwYDVR0jBBgwFoAU4TqrIXzx401UrtRyt/2L\nWUIgkFQwHQYDVR0OBBYEFD7cINmJRpiHe4c/2IFHslwV2EcGMB0GA1UdJQQWMBQG\nCCsGAQUFBwMBBggrBgEFBQcDAjBcBgNVHSAEVTBTMDkGCysGAQQBg44hAQABMCow\nKAYIKwYBBQUHAgEWHGh0dHA6Ly9yZXBvc2l0b3J5LmVtc2lnbi5jb20wDQYLKwYB\nBAGDjiEBAngwBwYFZ4EMAQEwdQYIKwYBBQUHAQEEaTBnMCQGCCsGAQUFBzABhhho\ndHRwOi8vb2NzcC1iLmVtc2lnbi5jb20wPwYIKwYBBQUHMAKGM2h0dHA6Ly9yZXBv\nc2l0b3J5LmVtc2lnbi5jb20vY2VydHMvRU1FVlRMU0NBRzNCLnA3YzA3BgNVHR8E\nMDAuMCygKqAohiZodHRwOi8vY3JsLmVtc2lnbi5jb20vP3Jvb3R0bHNjYWczLmNy\nbDAOBgNVHQ8BAf8EBAMCAQYwEgYDVR0TAQH/BAgwBgEB/wIBADAKBggqhkjOPQQD\nAwNpADBmAjEA9Jq999oaOUKhFwopw33iCmt0OHIn3gjT43GfuOHR/YckXdwoNdDA\nlIEhz0NgRO3/AjEAvY/a3OMvktIZLYjn+WLKmp0QhrIMV+e6EpS7j4GVeOKouNp6\nMN/P+ByGeiRMghzB\n-----END CERTIFICATE-----\n",
"f2edaaff3d9b701fb4d14c9f285f8c0bad90fc3bcc1fa07d47f4718edaba34a5": "-----BEGIN CERTIFICATE-----\nMIICKDCCAa2gAwIBAgIQAY/OySfBFvWcmX7cDM0uKzAKBggqhkjOPQQDAzBVMQsw\nCQYDVQQGEwJJTjElMCMGA1UECgwcZU11ZGhyYSBUZWNobm9sb2dpZXMgTGltaXRl\nZDEfMB0GA1UEAwwWZW1TaWduIFJvb3QgQ1MgQ0EgLSBHMzAeFw0yNDAyMDgwODMz\nMTRaFw00OTAyMDcwODMzMTRaMFUxCzAJBgNVBAYTAklOMSUwIwYDVQQKDBxlTXVk\naHJhIFRlY2hub2xvZ2llcyBMaW1pdGVkMR8wHQYDVQQDDBZlbVNpZ24gUm9vdCBD\nUyBDQSAtIEczMHYwEAYHKoZIzj0CAQYFK4EEACIDYgAESl9kM5D0VA/Og76dz5Xz\nw1hnGQ4fSR9993/qBkkvI48pXQVKOdyHCgAZThgWejQ8OpFRiTISjpabn4tfRFOY\nuzhApR/XxGbkg9X+2cEtbyghF9CdoF91W22V/R2EVe4xo0IwQDAdBgNVHQ4EFgQU\naHQD+IXKjVRQVx0W4MjeRQ2rzS0wDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF\nMAMBAf8wCgYIKoZIzj0EAwMDaQAwZgIxAK9OlACrFod3UgWY1ai+DF9sGFhahU59\nrv4jJyV01QaxGn0HErZ8gjHkngqe+tcFogIxAPTdW0hBmrGjhxBWdv9mZNdVFiJ4\nlrZ3ahvimsLhTQpnDBgiGKv6Ov1Fx/CE6yjIAg==\n-----END CERTIFICATE-----\n",
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe": "-----BEGIN CERTIFICATE-----\nMIICODCCAb6gAwIBAgIUPXjC97k7buQHZ7p5ehDU8QNyrR4wCgYIKoZIzj0EAwIw\nPzELMAkGA1UEBhMCTkwxEzARBgNVBAoMCmVNdWRocmEgQlYxGzAZBgNVBAMMEkVN\nIE9WIFRMUyBDQSAtIEczQjAeFw0yNjEwMjAwMDU5MzdaFw0yNzA1MDMwMTA0Mzda\nMC8xLTArBgNVBAMMJG92LWczYi1lbXVkaHJhLndlLWhhdmUtYS1wcm9ibGVtLmNv\nbTBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABA1haD+95Albk0UN6YtDCPpqevTv\nYUmEhsIVpb/hnhsbBlH0yLthSE2JLH14ywiQJXttoc/pDWC9eoICW6CJXL2jgacw\ngaQwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYB\nBQUHAwEwLwYDVR0RBCgwJoIkb3YtZzNiLWVtdWRocmEud2UtaGF2ZS1hLXByb2Js\nZW0uY29tMB0GA1UdDgQWBBQUhzc4s202Hn62G0srmgguYdY2MTAfBgNVHSMEGDAW\ngBSb2GVRr+fnLDetCO5pGdm/N4SuCjAKBggqhkjOPQQDAgNoADBlAjEAoJ6d4zzA\nxE349yGHHrwKZpVCC/HBFutcOpOfzQoe8ikbmaaBTdtKai5TEllwt3dBAjBeHGiX\nVheDhJfewbei99cw1MfYB5tPwdfuY5pe1MJz0Tg9zJ4JgxBvF6LTT0OOXZ4=\n-----END CERTIFICATE-----\n",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f": "-----BEGIN CERTIFICATE-----\nMIIDzTCCArWgAwIBAgIQBfB4dS3s56G7hZXTAxZxcTANBgkqhkiG9w0BAQsFADB7\nMQswCQYDVQQGEwJHQjEbMBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYD\nVQQHDAdTYWxmb3JkMRowGAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UE\nAwwYQUFBIENlcnRpZmljYXRlIFNlcnZpY2VzMB4XDTI1MDUxNDAwMDAwMFoXDTI4\nMTIzMTIzNTk1OVowQDELMAkGA1UEBhMCVVMxFDASBgNVBAoMC2VNdWRocmEgSW5j\nMRswGQYDVQQDDBJFTSBFViBUTFMgQ0EgLSBHM0MwdjAQBgcqhkjOPQIBBgUrgQQA\nIgNiAAQtVugppvuCxkpGl9lgr5UekGeukfkm00MG2kioqut4abamfm0nQm4EQqoD\ns24ZiN/2NlFO4c8H9bpLP/KgZkWufTHVhmrTGqPSRusLMXfumct1ZsUWznSAimaP\nkfDEf8qjggE0MIIBMDAfBgNVHSMEGDAWgBSgEQojPpbxB+zirynvgqV/0DCktDAd\nBgNVHQ4EFgQUpiVegKlNt/OWfBvHkd+SvO5L618wDgYDVR0PAQH/BAQDAgEGMBIG\nA1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0lBBYwFAYIKwYBBQUHAwEGCCsGAQUFBwMC\nMDAGA1UdIAQpMCcwBwYFZ4EMAQEwDQYLKwYBBAGDjiEBAAEwDQYLKwYBBAGDjiEB\nAngwQwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20vQUFB\nQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNAYIKwYBBQUHAQEEKDAmMCQGCCsGAQUF\nBzABhhhodHRwOi8vb2NzcC5jb21vZG9jYS5jb20wDQYJKoZIhvcNAQELBQADggEB\nAIFfHiaMSjnrdZygRYV4ekRBmG7ybcFk4hGQWMMyMFi0cAEk2+dGfz5bl8IaIbLq\nz+uVJzhOgVEX7WqIfgbWfFsfPYHYjPHBmy7SlZ3BwA86IQ6qF/etpX2/qL9A5yRN\nN3nVowUpaqV6H3aCpkAh3wR2ZNkvAbLDvPz3J0l+sEWjWv+ZISOV4R+hXDTdPXV0\n1lKxnbwqEsdo/sE2QkCDKwFrZh/0uKjWspydJj+jSaDLTrGNx50Dgmn7VEcD1ZI5\nFXjknVUiO25YlmS1dtVZSBbyUKSjlrP/yDwPcZunoAsmCAAtCKsFLviLaymzvsKI\neSSDGxpSySp2VCxompGDk8g=\n-----END CERTIFICATE-----\n"
},
"chains": {
"dv-g3b-aaa-emudhra.we-have-a-problem.com": [
"7e02622ab6d745c1edb4b431bb1c551e667305c3dd3eb0c7cc6dfdb523807424",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
],
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com": [
"5510ecf5764dee8dfe9468ae2d7323da21fcd5530889906540a2f8725c6f74db",
"45a883f349aedfd5f3a649b5498ffb121712ba8b448f3f2a91dbc5a766fcddbe"
],
"dv-g3b-emudhra.we-have-a-problem.com": [
"9dcb21b1841d75313b3c888f4bb9cc01f8f51379a76fbaa241ca492b668839e7",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"dv-g3b-noct-emudhra.we-have-a-problem.com": [
"0cbfe22b824385823fd3bf4be4d131e8901342933a7feef1cf8b3110b8e44a70",
"42c4accd12c4f59597925e1a08a7a90102608543809308d50ec427e118ba1493",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"dv-g3c-aaa-emudhra.we-have-a-problem.com": [
"9200d153c3cac272cc06bf68f9ea163fc24659fc3331e77668236fe00645ae52",
"62b7f9868263f72f6911fc78ceaf72c1979158b7368bf648683bb2aa39b02e72"
],
"dv-g3c-emudhra.we-have-a-problem.com": [
"346cdd962e97189b59aee5526d1ae8fa714bb1a64094dfa67bef768ee93f36b5",
"0164d7ceae5bb909c272af151ff44b1177ae9b47d9424b94f1d9558c30934d19",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ev-g3b-aaa-emudhra.we-have-a-problem.com": [
"bc88231a840e54cced148da48806169e8d0b1c0a96267a427aeccda9d10e6145",
"579480aea166290bd3c643589e795fc0d0e54e86505d180f1dff99efd3199512"
],
"ev-g3b-emudhra.we-have-a-problem.com": [
"66374ae64962c437bd815a2aae546c76e30c2eafac8335192c8e517c15a246ed",
"e7568fe6e12331f4fd7de61da4c7559d0f8eb192a218d4741a3ba75184333838",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ev-g3c-aaa-emudhra.we-have-a-problem.com": [
"bcd6ab59ea95fd520bfe7709679b1f682abbd79c77367e84e25415b91de37753",
"fd9d5c2dbbd4995ab69b8d50c439d9527b00529199c2fd01b2174eb58f07021f"
],
"ev-g3c-emudhra.we-have-a-problem.com": [
"ccd80467f9cac38f115dcb6e4f57e4d74f5efa519814ea934aeb3be53b83a191",
"432ec25f0d28de9006d97bf752f17bbb2cd41f669933fcb072d9a1148d0261f3",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ov-g3b-aaa-emudhra.we-have-a-problem.com": [
"21f57f83631a93c98e867050075746b3361e0433c9e0e969462852476784f6f0",
"5f1ded4dd590af74631290cf073b122a2aac6a1fa479778a0bd43579bd832699"
],
"ov-g3b-emudhra.we-have-a-problem.com": [
"f3c3c15bc7edf2b3ebdc922af112f2a0c414eb509901099ef0488c0942e77efe",
"98b896625c0c230adeec3eb2cf6aad5001ecf9156a68644e82374ce5833bc25b",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
],
"ov-g3c-aaa-emudhra.we-have-a-problem.com": [
"59cf0386b56bc2414a0c06241ea9e25e01a9c93bac7a596648747f9d0ded4906",
"7485b51c0ed192878fa1d2f948fb529ae1842cc5086c4b67f7be5b2039de9489"
],
"ov-g3c-emudhra.we-have-a-problem.com": [
"4314941cf242a4ea4149aa7e9cdf77a15de2771a79984b809fc04443fea013c8",
"cb6e15165ac802e9b3f993f16c25d41f4e81c21ebde71ae1b012ac00bf9c7c3f",
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd"
]
},
"cross_signed_roots": {
"3094025792039a68af6e553686410bdab7c0d4a36b9084cba64202e35d6ebbdd": "emSign Root TLS CA - G3 (emSign ECC Root CA - G3 cross-sign)"
}
}
+80 -4
View File
@@ -123,12 +123,88 @@ def test_blocks_listing_the_data_directory_itself():
_resolve_tool_path(DATA_DIR)
def test_blocks_app_state_reached_by_relative_path(monkeypatch):
"""The data directory is a relative hop from the checkout root, so
def test_blocks_app_state_reached_by_relative_path(tmp_path, monkeypatch):
"""The data directory is a relative hop from the agent workspace, so
confinement cannot depend on the model supplying an absolute path."""
monkeypatch.chdir(os.path.dirname(DATA_DIR))
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
with pytest.raises(ValueError, match="application state"):
_resolve_tool_path(os.path.join(os.path.basename(DATA_DIR), "sessions.json"))
current_execution._resolve_tool_path(os.path.join("..", "sessions.json"))
def test_relative_path_resolves_in_agent_workspace_not_server_cwd(tmp_path, monkeypatch):
"""With no workspace bound, bash runs in the agent workspace, so a
relative write_file/read_file path must land there too (#6679), not in
the server's cwd, which is outside the allowed roots."""
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
server_cwd = tmp_path / "checkout"
server_cwd.mkdir()
monkeypatch.chdir(server_cwd)
token = current_execution._active_workspace.set(None)
try:
assert current_execution._resolve_tool_path("test.txt") == os.path.join(
os.path.realpath(workspace), "test.txt"
)
assert current_execution._resolve_search_root("sub") == os.path.join(
os.path.realpath(workspace), "sub"
)
finally:
current_execution._active_workspace.reset(token)
def test_request_without_workspace_seals_the_agent_workspace(tmp_path, monkeypatch):
"""With no workspace, the native file tools get the folder bash/python
already run in (#6679) instead of no root at all, and a relative path
lands there while app state next to it stays out of reach."""
from src.agent_runtime.authority import ExactOperation, OperationGrant, RequestAuthority
from src.agent_runtime.resource_binding import resolve_filesystem_operation
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
workspace.mkdir()
(data_dir / "sessions.json").write_text("{}", encoding="utf-8")
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("write_file"),))
assert [r.path for r in authority.resource_roots] == [os.path.realpath(workspace)]
bound = resolve_filesystem_operation(
ExactOperation.normalize("write_file", "test.txt\nhello"), roots=authority.resource_roots)
assert bound.bindings[0].resource.path == os.path.join(os.path.realpath(workspace), "test.txt")
with pytest.raises(ValueError, match="outside the sealed roots"):
resolve_filesystem_operation(
ExactOperation.normalize("read_file", "../sessions.json"), roots=authority.resource_roots)
def test_symlinked_agent_workspace_is_not_sealed(tmp_path, monkeypatch):
"""A symlinked agent workspace must not become the file-tool root:
agent_cwd() and create_directories() refuse the same layout."""
from src.agent_runtime.authority import OperationGrant, RequestAuthority
current_execution = importlib.import_module("src.tool_execution")
data_dir = tmp_path / "data"
data_dir.mkdir()
workspace = _configure_test_data_tree(monkeypatch, data_dir)["AGENT_WORKSPACE_DIR"]
elsewhere = tmp_path / "elsewhere"
elsewhere.mkdir()
(elsewhere / "notes.txt").write_text("private", encoding="utf-8")
try:
workspace.symlink_to(elsewhere, target_is_directory=True)
except OSError:
pytest.skip("cannot create symlink")
monkeypatch.setattr(current_execution, "_AGENT_WORKDIR", str(workspace))
authority = RequestAuthority("request", "alice", "s", "", (OperationGrant("read_file"),))
assert authority.resource_roots == ()
def test_blocks_app_state_reached_through_a_symlink(tmp_path):
+2 -1
View File
@@ -281,7 +281,8 @@ async def test_browser_approval_application_binding_is_exact(producer, field, va
original = authority()
content = '{"action":"session_info"}'
approval = approval_for(original, "private_browser", content)
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=())
# resource_roots=None re-seals the root for the changed owner.
changed = replace(original, **{field: value}, browser_sessions=(), browser_pages=(), resource_roots=None)
_, result = await dispatch(changed, "private_browser", content, approval)
assert result["exit_code"] == 1 and not approval._claimed
+387
View File
@@ -0,0 +1,387 @@
"""Offline regressions for incident roots, unaffected roots, and CA directories."""
import contextlib
import hashlib
import importlib.util
import io
import json
import os
from pathlib import Path
import shutil
import ssl
import subprocess
import sys
import tempfile
from types import SimpleNamespace
import unittest
from unittest.mock import patch
ROOT = Path(__file__).resolve().parents[1]
SPEC = importlib.util.spec_from_file_location("check_ca_trust", ROOT / "scripts/check_ca_trust.py")
checker = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(checker)
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
class CATrustTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
# Public certificate data is embedded below. Tests create temporary
# PEM files from these constants; no fixture contains a private key.
cls.temporary = tempfile.TemporaryDirectory()
cls.fixtures = Path(cls.temporary.name)
for name, pem in PUBLIC_CERTIFICATES.items():
(cls.fixtures / name).write_text(pem)
for fingerprint, pem in INCIDENT["certificates"].items():
if hashlib.sha256(ssl.PEM_cert_to_DER_cert(pem)).hexdigest() != fingerprint:
raise ValueError("incident fixture fingerprint mismatch")
(cls.fixtures / f"{fingerprint}.pem").write_text(pem)
@classmethod
def tearDownClass(cls):
cls.temporary.cleanup()
def test_unaffected_emsign_root_remains_usable(self):
checker.check_bundle(self.fixtures / "emsign-g1.pem")
def test_verbose_python_check_reports_and_rejects_affected_user_bundle(self):
# A fresh interpreter must see persisted user packages, independently
# of pytest's imported certifi/pip modules and any active test venv.
with tempfile.TemporaryDirectory() as tmp:
home = Path(tmp)
env = dict(os.environ, HOME=str(home), PYTHONUSERBASE=str(home / ".local"))
env.pop("PYTHONNOUSERSITE", None)
env.pop("PYTHONPATH", None)
python = getattr(sys, "_base_executable", sys.executable)
site_result = subprocess.run(
[python, "-m", "site", "--user-site"], env=env, capture_output=True, text=True,
)
self.assertEqual(site_result.returncode, 0, site_result.stderr)
user_site = Path(site_result.stdout.strip())
for package in ("certifi", "pip", "pip/_vendor", "pip/_vendor/certifi"):
directory = user_site / package
directory.mkdir(parents=True, exist_ok=True)
(directory / "__init__.py").write_text(
"from pathlib import Path\ndef where(): return str(Path(__file__).with_name('cacert.pem'))\n"
)
application_bundle = user_site / "certifi/cacert.pem"
pip_bundle = user_site / "pip/_vendor/certifi/cacert.pem"
for bundle in (application_bundle, pip_bundle):
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"])
command = [python, str(ROOT / "scripts/check_ca_trust.py"), "--python", "--verbose"]
clean = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertEqual(clean.returncode, 0, clean.stderr)
self.assertIn(str(application_bundle), clean.stdout)
self.assertIn(str(pip_bundle), clean.stdout)
self.assertIn(str(home), clean.stdout)
self.assertIn("Python executable:", clean.stdout)
application_bundle.write_text(PUBLIC_CERTIFICATES["emsign-g3.pem"])
affected = subprocess.run(command, env=env, capture_output=True, text=True)
self.assertEqual(affected.returncode, 1, affected.stderr)
self.assertIn(str(application_bundle), affected.stderr)
self.assertIn("emSign ECC Root CA - G3", affected.stderr)
def test_compromised_emsign_path_is_rejected(self):
with self.assertRaisesRegex(ValueError, "emSign ECC Root CA - G3"):
checker.check_bundle(self.fixtures / "emsign-g3.pem")
def test_aaa_cross_sign_path_is_rejected(self):
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
checker.check_bundle(self.fixtures / "aaa.pem")
def test_mixed_bundle_does_not_hide_an_affected_root(self):
with tempfile.TemporaryDirectory() as tmp:
bundle = Path(tmp) / "mixed.pem"
bundle.write_bytes((self.fixtures / "emsign-g1.pem").read_bytes() + (self.fixtures / "aaa.pem").read_bytes())
with self.assertRaisesRegex(ValueError, "AAA Certificate Services"):
checker.check_bundle(bundle)
def test_all_published_intermediates_and_cross_signs_are_rejected(self):
self.assertEqual(len(INCIDENT["intermediates"]), 16)
for fingerprint, name in INCIDENT["intermediates"].items():
with self.subTest(certificate=name), self.assertRaisesRegex(ValueError, "affected CA certificates"):
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
def test_published_roots_and_cross_signed_issuer_are_rejected(self):
certificates = INCIDENT["roots"] | INCIDENT["cross_signed_roots"]
self.assertEqual(len(certificates), 5)
for fingerprint, name in certificates.items():
with self.subTest(certificate=name), self.assertRaises(ValueError):
checker.check_bundle(self.fixtures / f"{fingerprint}.pem")
def test_clean_roots_do_not_hide_an_added_compromised_intermediate(self):
fingerprint = next(iter(INCIDENT["intermediates"]))
with tempfile.TemporaryDirectory() as tmp:
bundle = Path(tmp) / "custom.pem"
bundle.write_text(PUBLIC_CERTIFICATES["emsign-g1.pem"] + INCIDENT["certificates"][fingerprint])
with self.assertRaisesRegex(ValueError, "EM DV TLS CA - G3B"):
checker.check_bundle(bundle)
@unittest.skipUnless(shutil.which("openssl"), "offline chain verification requires OpenSSL")
def test_published_chains_and_custom_anchor_with_affected_positive_control(self):
# Freeze time inside the public fixtures' validity interval. Otherwise
# expiry could make an ineffective trust-store fix appear successful.
help_result = subprocess.run(["openssl", "verify", "-help"], capture_output=True, text=True)
no_store = ["-no-CAstore"] if "-no-CAstore" in help_result.stdout + help_result.stderr else []
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
affected = directory / "affected.pem"
affected.write_text("".join(INCIDENT["certificates"][key] for key in INCIDENT["roots"]))
clean = self.fixtures / "emsign-g1.pem"
custom = directory / "custom.pem"
fingerprint = next(iter(INCIDENT["intermediates"]))
custom.write_text(clean.read_text() + INCIDENT["certificates"][fingerprint])
self.assertEqual(len(INCIDENT["chains"]), 14)
custom_accepted = []
for hostname, chain in INCIDENT["chains"].items():
leaf, intermediates = directory / "leaf.pem", directory / "chain.pem"
leaf.write_text(INCIDENT["certificates"][chain[0]])
intermediates.write_text("".join(INCIDENT["certificates"][key] for key in chain[1:]))
def verify(bundle):
return subprocess.run([
"openssl", "verify", "-no-CApath", *no_store,
"-attime", str(INCIDENT["verification_time"]),
"-partial_chain", "-x509_strict", "-purpose", "sslserver",
"-verify_hostname", hostname, "-CAfile", str(bundle),
"-untrusted", str(intermediates), str(leaf),
], capture_output=True, text=True)
with self.subTest(hostname=hostname):
positive = verify(affected)
self.assertEqual(positive.returncode, 0, positive.stderr)
negative = verify(clean)
self.assertNotEqual(negative.returncode, 0)
self.assertIn("unable to get local issuer certificate", negative.stderr)
if verify(custom).returncode == 0:
custom_accepted.append(hostname)
self.assertCountEqual(custom_accepted, [
"dv-g3b-aaa-emudhra.we-have-a-problem.com",
"dv-g3b-aaa-noct-emudhra.we-have-a-problem.com",
"dv-g3b-emudhra.we-have-a-problem.com",
"dv-g3b-noct-emudhra.we-have-a-problem.com",
])
checker.check_bundle(clean)
for bundle in (affected, custom):
with self.subTest(bundle=bundle.name), self.assertRaises(ValueError):
checker.check_bundle(bundle)
def test_empty_malformed_and_missing_bundles_fail_closed(self):
with tempfile.TemporaryDirectory() as tmp:
for content in ("", "not a certificate"):
path = Path(tmp) / "bad.pem"
path.write_text(content)
with self.subTest(content=content), self.assertRaises((ssl.SSLError, ValueError)):
checker.check_bundle(path)
with self.assertRaises(OSError):
checker.check_bundle(Path(tmp) / "missing.pem")
def test_each_configured_override_is_checked(self):
fingerprint = next(iter(INCIDENT["intermediates"]))
affected = self.fixtures / f"{fingerprint}.pem"
clean = self.fixtures / "emsign-g1.pem"
for name in ("LLM_CA_BUNDLE", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE",
"PIP_CERT", "NODE_EXTRA_CA_CERTS", "GRPC_DEFAULT_SSL_ROOTS_FILE_PATH"):
for bundle, expected in ((clean, 0), (affected, 1)):
with self.subTest(variable=name, bundle=bundle.name), patch.dict(
os.environ, {name: str(bundle)}, clear=True
), contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides", str(clean)]), expected)
def test_invalid_override_is_not_hidden_by_a_clean_explicit_bundle(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
for name, content in (("empty.pem", ""), ("malformed.pem", "invalid")):
(directory / name).write_text(content)
for name in ("missing.pem", "empty.pem", "malformed.pem"):
with self.subTest(file=name), patch.dict(
os.environ, {"LLM_CA_BUNDLE": str(directory / name)}, clear=True
), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides", str(self.fixtures / "emsign-g1.pem")]), 1)
def test_no_configured_overrides_does_not_report_validated_stores(self):
with patch.dict(os.environ, {"LLM_CA_BUNDLE": " "}, clear=True), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides"]), 1)
def test_requests_directory_override_checks_hashed_intermediates(self):
fingerprint = next(iter(INCIDENT["intermediates"]))
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
for name in ("REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE", "PIP_CERT"):
with self.subTest(variable=name), patch.dict(os.environ, {name: str(directory)}, clear=True):
for content, expected in ((PUBLIC_CERTIFICATES["emsign-g1.pem"], 0),
(INCIDENT["certificates"][fingerprint], 1)):
(directory / "12345678.0").write_text(content)
with contextlib.redirect_stdout(io.StringIO()), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides"]), expected)
(directory / "12345678.0").unlink()
with contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--overrides"]), 1)
def test_safe_cafile_does_not_hide_stale_hashed_directory(self):
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
(directory / "14bc7599.0").symlink_to(self.fixtures / "emsign-g3.pem")
defaults = ssl.get_default_verify_paths()._replace(
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
)
errors = io.StringIO()
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
os.environ, {defaults.openssl_capath_env: str(directory)}
):
with contextlib.redirect_stderr(errors):
result = checker.main(["--system"])
self.assertEqual(result, 1)
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
def test_hashed_non_ca_certificate_does_not_reject_clean_store(self):
# Ubuntu's OpenSSL directory can also hash ssl-cert-snakeoil.pem.
# A valid server certificate has no CA entries in get_ca_certs().
with tempfile.TemporaryDirectory() as tmp:
directory = Path(tmp)
(directory / "ce275665.0").symlink_to(self.fixtures / "localhost-leaf.pem")
defaults = ssl.get_default_verify_paths()._replace(
cafile=str(self.fixtures / "emsign-g1.pem"), capath=str(directory)
)
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
os.environ, {defaults.openssl_capath_env: str(directory)}
), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(checker.main(["--system"]), 0)
def test_multiple_ca_directories_are_all_checked(self):
with tempfile.TemporaryDirectory() as tmp:
clean, stale = Path(tmp) / "clean", Path(tmp) / "stale"
clean.mkdir()
stale.mkdir()
(stale / "ee64a828.0").symlink_to(self.fixtures / "aaa.pem")
defaults = ssl.get_default_verify_paths()._replace(
cafile=str(self.fixtures / "emsign-g1.pem"), capath=None
)
errors = io.StringIO()
with patch.object(checker.ssl, "get_default_verify_paths", return_value=defaults), patch.dict(
os.environ, {defaults.openssl_capath_env: os.pathsep.join(map(str, (clean, stale)))}
):
with contextlib.redirect_stderr(errors):
result = checker.main(["--system"])
self.assertEqual(result, 1)
self.assertIn("AAA Certificate Services", errors.getvalue())
def test_grpc_bundled_roots_are_checked_without_an_override(self):
with tempfile.TemporaryDirectory() as temporary:
package = Path(temporary) / "grpc"
bundle = package / "_cython" / "_credentials" / "roots.pem"
bundle.parent.mkdir(parents=True)
bundle.write_bytes((self.fixtures / "emsign-g3.pem").read_bytes())
errors = io.StringIO()
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
checker.importlib.util, "find_spec", return_value=SimpleNamespace(origin=str(package / "__init__.py"))
), contextlib.redirect_stderr(errors):
self.assertEqual(checker.main(["--grpc"]), 1)
self.assertIn("emSign ECC Root CA - G3", errors.getvalue())
def test_grpc_explicit_clean_store_replaces_the_independent_bundle(self):
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(self.fixtures / "emsign-g1.pem")}), patch.object(
checker.importlib.util, "find_spec", side_effect=AssertionError("must use the explicit store")
), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 0)
def test_grpc_invalid_override_does_not_fall_back_silently(self):
with tempfile.TemporaryDirectory() as temporary:
for name, content in (("empty.pem", ""), ("malformed.pem", "not a certificate"), ("affected.pem", PUBLIC_CERTIFICATES["aaa.pem"])):
bundle = Path(temporary) / name
bundle.write_text(content)
with self.subTest(name=name), patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(bundle)}), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 1)
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": str(Path(temporary) / "missing.pem")}), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 1)
def test_grpc_check_reports_a_missing_installation(self):
with patch.dict(os.environ, {"GRPC_DEFAULT_SSL_ROOTS_FILE_PATH": ""}), patch.object(
checker.importlib.util, "find_spec", return_value=None
), contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(checker.main(["--grpc"]), 1)
# Public Mozilla CA certificates; provenance: tests/fixtures/ca-trust/README.md.
PUBLIC_CERTIFICATES = {
'localhost-leaf.pem': """-----BEGIN CERTIFICATE-----
MIIBOTCB7KADAgECAhRcVxnOQNEFWf92K4dm4I8ChyjVMzAFBgMrZXAwFDESMBAG
A1UEAwwJbG9jYWxob3N0MB4XDTI2MTAyMTE4NTgwN1oXDTM2MTAxODE4NTgwN1ow
FDESMBAGA1UEAwwJbG9jYWxob3N0MCowBQYDK2VwAyEARglphvNgp9qRCESLHnuu
W8VC5urdZl+YgS+Ux1BoLryjUDBOMB0GA1UdDgQWBBSaEckyR/76lX3a5Ijh8B42
oJwN1DAfBgNVHSMEGDAWgBSaEckyR/76lX3a5Ijh8B42oJwN1DAMBgNVHRMBAf8E
AjAAMAUGAytlcANBAJkq9ayy+d4AUZnjhWM31rFke5DT+l9QFcPl34+dpVhqJV/L
m4IpPHYagqrBRHn9Dv/C8qYyPBKnAwJRpNptsgw=
-----END CERTIFICATE-----
""",
'aaa.pem': """-----BEGIN CERTIFICATE-----
MIIEMjCCAxqgAwIBAgIBATANBgkqhkiG9w0BAQUFADB7MQswCQYDVQQGEwJHQjEb
MBkGA1UECAwSR3JlYXRlciBNYW5jaGVzdGVyMRAwDgYDVQQHDAdTYWxmb3JkMRow
GAYDVQQKDBFDb21vZG8gQ0EgTGltaXRlZDEhMB8GA1UEAwwYQUFBIENlcnRpZmlj
YXRlIFNlcnZpY2VzMB4XDTA0MDEwMTAwMDAwMFoXDTI4MTIzMTIzNTk1OVowezEL
MAkGA1UEBhMCR0IxGzAZBgNVBAgMEkdyZWF0ZXIgTWFuY2hlc3RlcjEQMA4GA1UE
BwwHU2FsZm9yZDEaMBgGA1UECgwRQ29tb2RvIENBIExpbWl0ZWQxITAfBgNVBAMM
GEFBQSBDZXJ0aWZpY2F0ZSBTZXJ2aWNlczCCASIwDQYJKoZIhvcNAQEBBQADggEP
ADCCAQoCggEBAL5AnfRu4ep2hxxNRUSOvkbIgwadwSr+GB+O5AL686tdUIoWMQua
BtDFcCLNSS1UY8y2bmhGC1Pqy0wkwLxyTurxFa70VJoSCsN6sjNg4tqJVfMiWPPe
3M/vg4aijJRPn2jymJBGhCfHdr/jzDUsi14HZGWCwEiwqJH5YZ92IFCokcdmtet4
YgNW8IoaE+oxox6gmf049vYnMlhvB/VruPsUK6+3qszWY19zjNoFmag4qMsXeDZR
rOme9Hg6jc8P2ULimAyrL58OAd7vn5lJ8S3frHRNG5i1R8XlKdH5kBjHYpy+g8cm
ez6KJcfA3Z3mNWgQIJ2P2N7Sw4ScDV7oL8kCAwEAAaOBwDCBvTAdBgNVHQ4EFgQU
oBEKIz6W8Qfs4q8p74Klf9AwpLQwDgYDVR0PAQH/BAQDAgEGMA8GA1UdEwEB/wQF
MAMBAf8wewYDVR0fBHQwcjA4oDagNIYyaHR0cDovL2NybC5jb21vZG9jYS5jb20v
QUFBQ2VydGlmaWNhdGVTZXJ2aWNlcy5jcmwwNqA0oDKGMGh0dHA6Ly9jcmwuY29t
b2RvLm5ldC9BQUFDZXJ0aWZpY2F0ZVNlcnZpY2VzLmNybDANBgkqhkiG9w0BAQUF
AAOCAQEACFb8AvCb6P+k+tZ7xkSAzk/ExfYAWMymtrwUSWgEdujm7l3sAg9g1o1Q
GE8mTgHj5rCl7r+8dFRBv/38ErjHT1r0iWAFf2C3BUrz9vHCv8S5dIa2LX1rzNLz
Rt0vxuBqw8M0Ayx9lt1awg6nCpnBBYurDC/zXDrPbDdVCYfeU0BsWO/8tqtlbgT2
G9w84FoVxp7Z8VlIMCFlA2zs6SFz7JsDoeA3raAVGI/6ugLOpyypEBMs1OUIJqsi
l2D4kF501KKaU73yqWjgom7C12yxow+ev+to51byrvLjKzg6CYG1a4XXvi3tPxq3
smPi9WIsgtRqAEFQ8TmDn5XpNpaYbg==
-----END CERTIFICATE-----
""",
'emsign-g1.pem': """-----BEGIN CERTIFICATE-----
MIIDlDCCAnygAwIBAgIKMfXkYgxsWO3W2DANBgkqhkiG9w0BAQsFADBnMQswCQYD
VQQGEwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBU
ZWNobm9sb2dpZXMgTGltaXRlZDEcMBoGA1UEAxMTZW1TaWduIFJvb3QgQ0EgLSBH
MTAeFw0xODAyMTgxODMwMDBaFw00MzAyMTgxODMwMDBaMGcxCzAJBgNVBAYTAklO
MRMwEQYDVQQLEwplbVNpZ24gUEtJMSUwIwYDVQQKExxlTXVkaHJhIFRlY2hub2xv
Z2llcyBMaW1pdGVkMRwwGgYDVQQDExNlbVNpZ24gUm9vdCBDQSAtIEcxMIIBIjAN
BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAk0u76WaK7p1b1TST0Bsew+eeuGQz
f2N4aLTNLnF115sgxk0pvLZoYIr3IZpWNVrzdr3YzZr/k1ZLpVkGoZM0Kd0WNHVO
8oG0x5ZOrRkVUkr+PHB1cM2vK6sVmjM8qrOLqs1D/fXqcP/tzxE7lM5OMhbTI0Aq
d7OvPAEsbO2ZLIvZTmmYsvePQbAyeGHWDV/D+qJAkh1cF+ZwPjXnorfCYuKrpDhM
tTk1b+oDafo6VGiFbdbyL0NVHpENDtjVaqSW0RM8LHhQ6DqS0hdW5TUaQBw+jSzt
Od9C4INBdN+jzcKGYEho42kLVACL5HZpIQ15TjQIXhTCzLG3rdd8cIrHhQIDAQAB
o0IwQDAdBgNVHQ4EFgQU++8Nhp6w492pufEhF38+/PB3KxowDgYDVR0PAQH/BAQD
AgEGMA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEBAFn/8oz1h31x
PaOfG1vR2vjTnGs2vZupYeveFix0PZ7mddrXuqe8QhfnPZHr5X3dPpzxz5KsbEjM
wiI/aTvFthUvozXGaCocV685743QNcMYDHsAVhzNixl03r4PEuDQqqE/AjSxcM6d
GNYIAwlG7mDgfrbESQRRfXBgvKqy/3lyeqYdPV8q+Mri/Tm3R7nrft8EI6/6nAYH
6ftjk4BAtcZsCjEozgyfz7MjNYBBjWzEN3uBL4ChQEKF6dk4jeihU80Bv2noWgby
RQuQ+q7hv53yrlc8pa6yVvSLZUDp/TGBLPQ5Cdjua6e0ph0VpZj3AYHYhX3zUVxx
iN66zB+Afko=
-----END CERTIFICATE-----
""",
'emsign-g3.pem': """-----BEGIN CERTIFICATE-----
MIICTjCCAdOgAwIBAgIKPPYHqWhwDtqLhDAKBggqhkjOPQQDAzBrMQswCQYDVQQG
EwJJTjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNo
bm9sb2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0g
RzMwHhcNMTgwMjE4MTgzMDAwWhcNNDMwMjE4MTgzMDAwWjBrMQswCQYDVQQGEwJJ
TjETMBEGA1UECxMKZW1TaWduIFBLSTElMCMGA1UEChMcZU11ZGhyYSBUZWNobm9s
b2dpZXMgTGltaXRlZDEgMB4GA1UEAxMXZW1TaWduIEVDQyBSb290IENBIC0gRzMw
djAQBgcqhkjOPQIBBgUrgQQAIgNiAAQjpQy4LRL1KPOxst3iAhKAnjlfSU2fySU0
WXTsuwYc58Byr+iuL+FBVIcUqEqy6HyC5ltqtdyzdc6LBtCGI79G1Y4PPwT01xyS
fvalY8L1X44uT6EYGQIrMgqCZH0Wk9GjQjBAMB0GA1UdDgQWBBR8XQKEE9TMipuB
zhccLikenEhjQjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAKBggq
hkjOPQQDAwNpADBmAjEAvvNhzwIQHWSVB7gYboiFBS+DCBeQyh+KTOgNG3qxrdWB
CUfvO6wIBHxcmbHtRwfSAjEAnbpV/KlK6O3t5nYBQnvI+GDZjVGLVTv7jHvrZQnD
+JbNR6iC8hZVdyR+EhCVBCyj
-----END CERTIFICATE-----
""",
}
if __name__ == "__main__":
unittest.main()
+41
View File
@@ -0,0 +1,41 @@
"""The managed browser policy must distrust exactly the incident's eight keys."""
import base64
import hashlib
import json
from pathlib import Path
import unittest
from cryptography import x509
from cryptography.hazmat.primitives import serialization
ROOT = Path(__file__).resolve().parents[1]
# Public incident inventory: impacted-intermediates.json, 2026-10-21.
COMPROMISED_SPKIS = {
"43bffa19d6ef651451f6841e019931e96f6f2eaeff28ab4b6f1ce4576de2717b",
"49dd353dc2b919a3770645117d0e60c16115e7d1bc259eefe734b00ea434fe5f",
"5c0660a2b164910d8ff589c18e14552e9e0ec237dcefe2cb736d41a4811f81a0",
"63ecefee431d49523bc1136c4e72d0206a477086caf606cd5cf025686c109cf8",
"92c063d67e8f14db71d288e11266ffdaf69cbf115f7bab14f3bc2f68d25e0e30",
"e0254c7fcdc265e2f0f8a428dabb75ab4ae039b4872d62f5ee57f0877d5a8a41",
"ebed6475c2c2d80fcb199f3fe8dd6e6b3ce60b77eb15d4e42c3dec0c6f5b5105",
"ec4ffa1ad0ac88081314b7aaffef6a91f97cd57291bb5ba686c5415f5eb4f6b6",
}
class ChromiumCATrustTests(unittest.TestCase):
def test_policy_distrusts_each_compromised_key_without_adding_trust(self):
policy = json.loads((ROOT / "docker" / "chromium-emudhra.json").read_text())
self.assertEqual(set(policy), {"CADistrustedCertificates"})
self.assertEqual(len(policy["CADistrustedCertificates"]), 8)
keys = set()
for encoded in policy["CADistrustedCertificates"]:
certificate = x509.load_der_x509_certificate(base64.b64decode(encoded, validate=True))
self.assertTrue(certificate.extensions.get_extension_for_class(x509.BasicConstraints).value.ca)
self.assertNotEqual(certificate.subject, certificate.issuer)
public_key = certificate.public_key().public_bytes(
serialization.Encoding.DER, serialization.PublicFormat.SubjectPublicKeyInfo
)
keys.add(hashlib.sha256(public_key).hexdigest())
self.assertEqual(keys, COMPROMISED_SPKIS)
+13
View File
@@ -33,3 +33,16 @@ def test_python_tests_are_authoritative():
assert "python -m pytest -q" in python_tests
assert "continue-on-error:" not in python_tests
def test_ci_checks_system_trust_before_project_installation():
python_tests = _indented_block(_WORKFLOW.read_text(), "python-tests", 2)
system_check = python_tests.index("python scripts/check_ca_trust.py --system\n")
system_update = python_tests.index("sudo apt-get install -y --no-install-recommends ffmpeg bubblewrap ca-certificates")
bootstrap = python_tests.index("uv pip install --system --require-hashes")
python_check = python_tests.index("python scripts/check_ca_trust.py --python\n")
application_install = python_tests.index("uv pip install --system -r requirements.txt")
assert system_update < system_check < bootstrap < python_check < application_install
assert system_check < python_tests.index("npm ci")
assert system_check < python_tests.index("npx playwright install")
+111
View File
@@ -0,0 +1,111 @@
"""Agent chat history uses the same scope as existing chat and history routes."""
import pytest
from fastapi import APIRouter, HTTPException
from starlette.requests import Request
from routes import codex_routes
def _endpoint(router, method: str, path: str):
for route in router.routes:
if route.path == path and method in route.methods:
return route.endpoint
raise AssertionError(f"{method} {path} route not found")
def _request(scopes: list[str]) -> Request:
request = Request({
"type": "http",
"method": "GET",
"path": "/api/codex/chat/session-test",
"headers": [],
"state": {},
})
request.state.current_user = "api"
request.state.api_token = True
request.state.api_token_owner = "alice"
request.state.api_token_scopes = scopes
return request
def _history_router(calls: list[tuple]):
router = APIRouter()
@router.get("/api/history/{session_id}")
async def history(
request: Request,
session_id: str,
limit: int | None = None,
offset: int | None = None,
):
calls.append((request, session_id, limit, offset))
return {
"name": "Model debugging",
"model": "org/model",
"history": [{"role": "assistant", "content": "second"}],
"total": 3,
"offset": offset,
"limit": limit,
"endpoint_url": "http://private-endpoint",
}
return router
@pytest.mark.asyncio
async def test_history_read_uses_existing_paged_handler_without_exposing_endpoint_url():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
request = _request(["chat"])
result = await read_chat(request, "session-test", offset=1, limit=1)
assert calls == [(request, "session-test", 1, 1)]
assert result == {
"session_id": "session-test",
"name": "Model debugging",
"model": "org/model",
"messages": [{"role": "assistant", "content": "second"}],
"total": 3,
"offset": 1,
"limit": 1,
}
@pytest.mark.asyncio
async def test_token_without_chat_scope_cannot_read_history_or_reach_handler():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
with pytest.raises(HTTPException) as exc_info:
await read_chat(_request(["todos:read"]), "session-test")
assert exc_info.value.status_code == 403
assert calls == []
@pytest.mark.asyncio
async def test_history_read_uses_existing_page_limit():
calls = []
router = codex_routes.setup_codex_routes(history_router=_history_router(calls))
read_chat = _endpoint(router, "GET", "/api/codex/chat/{session_id}")
await read_chat(_request(["chat"]), "session-test", limit=1000)
assert calls[0][2:] == (100, 0)
def test_capabilities_report_history_permission_from_chat_scope():
router = codex_routes.setup_codex_routes(history_router=_history_router([]))
capabilities = _endpoint(router, "GET", "/api/codex/capabilities")
without_chat = capabilities(_request(["todos:read"]))["tools"]["chat"]
reader = capabilities(_request(["chat"]))["tools"]["chat"]
assert without_chat["read"] is False
assert reader["read"] is True
assert reader["available"] is True
assert reader["actions"] == ["read"]
+34
View File
@@ -0,0 +1,34 @@
"""Sidecar images must be pinned to a versioned tag and an immutable digest.
A bare or ``:latest`` reference changes underneath an existing install on the
next ``docker compose pull``/``up``, so two hosts on the same commit can run
different ChromaDB or ntfy builds. The ``odysseus`` service is excluded: it is
the project's own image, selected through ``ODYSSEUS_IMAGE``. The standalone
GPU files are held equal to these services by test_gpu_compose_standalone.py.
"""
import re
from pathlib import Path
import pytest
import yaml
COMPOSE = Path(__file__).resolve().parents[1] / "docker-compose.yml"
PINNED = re.compile(r"^[\w.\-/]+:(?P<tag>[\w.\-]+)@sha256:[0-9a-f]{64}$")
def _sidecars():
services = yaml.safe_load(COMPOSE.read_text(encoding="utf-8"))["services"]
return {name: svc["image"] for name, svc in services.items() if name != "odysseus"}
def test_expected_sidecars_present():
assert {"chromadb", "searxng", "ntfy"} <= set(_sidecars())
@pytest.mark.parametrize("name", sorted(_sidecars()))
def test_sidecar_image_has_version_tag_and_digest(name):
image = _sidecars()[name]
match = PINNED.match(image)
assert match, f"{name}: expected <repo>:<tag>@sha256:<digest>, got {image!r}"
assert match.group("tag") != "latest", f"{name}: pin a release tag, not latest"
@@ -0,0 +1,136 @@
"""Read-only Cookbook tools must be admin-only for the agent.
Regression: `list_cached_models`, `list_served_models`, `tail_serve_output`,
`list_downloads`, `search_hf_models`, `list_serve_presets` and
`list_cookbook_servers` sat in the plan-mode read-only allowlist but not in
NON_ADMIN_BLOCKED_TOOLS or the dispatcher's _ADMIN_TOOLS. Their
implementations call require_admin routes over the internal loopback with the
process-wide INTERNAL_TOOL_TOKEN and never look at the owner, so a non-admin
user's agent could reach admin capability through them. The worst case is
`list_cached_models`: a caller-chosen `host` is forwarded to
/api/model/cached, which SSHes to that host with the app user's keys and
walks caller-chosen `model_dir`s. THREAT_MODEL.md lists model serving as
admin-only, so the read side has to be gated the same way as serve/download.
"""
import sys
import types
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from src.tool_security import (
NON_ADMIN_BLOCKED_TOOLS,
blocked_tools_for_owner,
delegated_credential_blocked_tools,
)
from src.tool_execution import _ADMIN_TOOLS
from src.agent_runtime.authority import OperationGrant, RequestAuthority
READONLY_COOKBOOK_TOOLS = (
"list_cached_models",
"list_served_models",
"tail_serve_output",
"list_downloads",
"search_hf_models",
"list_serve_presets",
"list_cookbook_servers",
)
def _install_core_auth_stub(monkeypatch, *, is_admin: bool):
"""Narrow auth surface: AUTH enabled, configured, owner admin or not."""
core_mod = types.ModuleType("core")
core_mod.__path__ = []
auth_mod = types.ModuleType("core.auth")
class FakeAuth:
is_configured = True
def is_admin(self, username):
return is_admin
auth_mod.AuthManager = lambda: FakeAuth()
core_mod.auth = auth_mod
monkeypatch.setitem(sys.modules, "core", core_mod)
monkeypatch.setitem(sys.modules, "core.auth", auth_mod)
monkeypatch.setattr("src.auth_helpers._auth_disabled", lambda: False)
return auth_mod
def test_readonly_cookbook_tools_are_in_every_admin_gate():
from src.agent_loop import _ADMIN_TOOLS as prompt_admin_tools
# Spelled out (not imported from a shared tuple in src/) so dropping one
# from either set fails here instead of silently reopening the hole.
for tool in READONLY_COOKBOOK_TOOLS:
assert tool in NON_ADMIN_BLOCKED_TOOLS, tool
assert tool in _ADMIN_TOOLS, tool
assert tool in prompt_admin_tools, tool
def test_readonly_cookbook_tools_hidden_from_non_admin_and_bearer_runs(monkeypatch):
_install_core_auth_stub(monkeypatch, is_admin=False)
hidden = blocked_tools_for_owner("regular-user")
for tool in READONLY_COOKBOOK_TOOLS:
assert tool in hidden, tool
assert tool in delegated_credential_blocked_tools(), tool
def test_admin_owner_keeps_readonly_cookbook_tools(monkeypatch):
_install_core_auth_stub(monkeypatch, is_admin=True)
assert blocked_tools_for_owner("admin-user") == set()
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
async def test_non_admin_agent_cannot_execute_readonly_cookbook_tools(monkeypatch, tool_name):
_install_core_auth_stub(monkeypatch, is_admin=False)
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
# The loopback must never be touched: if the gate fails, the tool would
# hit a require_admin route with the internal token.
import httpx
def _boom(*args, **kwargs):
raise AssertionError(f"{tool_name} reached the internal loopback for a non-admin owner")
monkeypatch.setattr(httpx, "AsyncClient", _boom)
monkeypatch.setattr(httpx, "get", _boom)
monkeypatch.setattr(httpx, "post", _boom)
payload = '{"host": "root@10.0.0.5", "ssh_port": "22", "model_dir": "/root,/etc"}'
desc, result = await execute_tool_block(
SimpleNamespace(tool_type=tool_name, content=payload),
owner="regular-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
request_authority=RequestAuthority(
"cookbook-test", "regular-user", "", "", (OperationGrant(tool_name),)
),
)
assert desc == f"{tool_name}: BLOCKED"
assert result["exit_code"] == 1
assert "admin" in result["error"]
@pytest.mark.asyncio
@pytest.mark.parametrize("tool_name", READONLY_COOKBOOK_TOOLS)
async def test_admin_agent_can_dispatch_readonly_cookbook_tools(monkeypatch, tool_name):
from src import tool_implementations
from src.tool_execution import NO_TOOL_SECURITY_CONTEXT, execute_tool_block
_install_core_auth_stub(monkeypatch, is_admin=True)
handler = AsyncMock(return_value={"output": "Cookbook result", "exit_code": 0})
monkeypatch.setattr(tool_implementations, f"do_{tool_name}", handler)
desc, result = await execute_tool_block(
SimpleNamespace(tool_type=tool_name, content="{}"),
owner="admin-user",
security_context=NO_TOOL_SECURITY_CONTEXT,
request_authority=RequestAuthority(
"cookbook-test", "admin-user", "", "", (OperationGrant(tool_name),)
),
)
assert desc == tool_name
assert result["exit_code"] == 0
handler.assert_awaited_once_with("{}", owner="admin-user")
+63
View File
@@ -0,0 +1,63 @@
"""Execute the publication workflow's image-name setup without contacting GHCR."""
import os
import shutil
import subprocess
from pathlib import Path
import pytest
import yaml
WORKFLOW = (
Path(__file__).resolve().parents[1]
/ ".github"
/ "workflows"
/ "docker-publish.yml"
)
@pytest.mark.skipif(shutil.which("bash") is None, reason="Docker publication requires Bash")
@pytest.mark.parametrize("job", ["build", "merge"])
@pytest.mark.parametrize(
("repository", "expected"),
[
("RaresKeY/odysseus-dev", "rareskey/odysseus-dev"),
("ExampleOwner/Example-Repo", "exampleowner/example-repo"),
("odysseus-dev/odysseus", "odysseus-dev/odysseus"),
],
)
def test_image_name_is_lowercase_before_docker_steps(tmp_path, job, repository, expected):
workflow = yaml.safe_load(WORKFLOW.read_text(encoding="utf-8"))
image_name = workflow["env"].get("IMAGE_NAME", "").replace(
"${{ github.repository }}", repository
)
github_env = tmp_path / "github-env"
env = {
"PATH": os.environ["PATH"],
"GITHUB_REPOSITORY": repository,
"GITHUB_ENV": str(github_env),
"GITHUB_OUTPUT": str(tmp_path / "github-output"),
"GITHUB_SHA": "a" * 40,
}
# Run the real setup shell before the job starts using Docker actions.
# Checkout, login, builds and registry publication are deliberately excluded.
for step in workflow["jobs"][job]["steps"]:
if step.get("uses", "").startswith("docker/"):
break
if "run" not in step:
continue
subprocess.run(
["bash", "--noprofile", "--norc", "-e", "-o", "pipefail", "-c", step["run"]],
cwd=WORKFLOW.parents[2],
env=env,
check=True,
capture_output=True,
text=True,
)
if github_env.exists():
env.update(line.split("=", 1) for line in github_env.read_text().splitlines())
image_name = env.get("IMAGE_NAME", image_name)
assert image_name == expected
+6 -8
View File
@@ -1,9 +1,10 @@
"""Repository asset ownership guards for issues #1335 and #6175.
Public Markdown and landing-page media belong in website/, while shared
README/packaging imagery belongs in assets/branding/. Images in either managed
root must be referenced by tracked text, and every tracked website video must
be referenced by the site's entry point.
optional README/packaging imagery belongs in assets/branding/. Retained images
in either managed root must be referenced by tracked text, and every retained
website video must be referenced by the site's entry point. Publication can
operate with no imagery or videos.
"""
import re
import subprocess
@@ -41,7 +42,8 @@ def _tracked(*paths_under):
return None
if out.returncode != 0:
return None
return [REPO / line for line in out.stdout.splitlines() if line.strip()]
return [REPO / line for line in out.stdout.splitlines()
if line.strip() and (REPO / line).is_file()]
def test_no_orphan_documentation_or_branding_images():
@@ -49,9 +51,6 @@ def test_no_orphan_documentation_or_branding_images():
if managed_files is None:
pytest.skip("not a git checkout")
managed_images = [p for p in managed_files if p.suffix.lower() in IMAGE_EXTS]
assert any("assets/branding" in p.as_posix() for p in managed_images), (
"expected assets/branding/ to contain the shared project imagery"
)
# All tracked text we might reference an image from.
all_tracked = _tracked(".") or []
@@ -94,7 +93,6 @@ def test_pages_site_owns_its_entrypoint_and_media():
assert text.startswith("---\nlayout: default\n---\n"), guide
website_videos = [p for p in website_files if p.suffix.lower() in VIDEO_EXTS]
assert website_videos, "expected website/ to contain the landing-page videos"
entrypoint = (REPO / "website/index.html").read_text(encoding="utf-8")
unreferenced = [
@@ -39,6 +39,7 @@ def test_resolve_model_keeps_request_when_served_list_unknown():
def test_self_check_resolves_the_model_before_calling(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: ["http://editor.test/v1"])
base = eam.local_bases()[0]
_cache(base, ["Qwen/Qwen3.5-9B"])
seen = {}
@@ -56,6 +57,7 @@ def test_self_check_resolves_the_model_before_calling(monkeypatch):
def test_caption_resolves_the_caption_model(monkeypatch):
monkeypatch.setattr(eam, "caption_base", lambda: "http://caption.test/v1")
seen = {}
async def fake_post(b, model, messages, headers, timeout, **kwargs):
@@ -69,3 +71,13 @@ def test_caption_resolves_the_caption_model(monkeypatch):
txt = asyncio.run(m.caption_image("data:image/png;base64,AAAA"))
assert txt == "a photo"
assert seen["model"] == "Qwen/Qwen3.5-9B"
def test_unconfigured_editor_helpers_do_not_probe_private_hosts(monkeypatch):
monkeypatch.setattr(eam, "local_bases", lambda: [])
monkeypatch.setattr(eam, "caption_base", lambda: "")
monkeypatch.setattr(eam, "probe_models", lambda *a: (_ for _ in ()).throw(AssertionError("unexpected probe")))
eam._probe_worker()
assert asyncio.run(eam.caption_image("data:image/png;base64,AAAA")) is None
result = asyncio.run(eam.self_check("data:image/png;base64,AAAA", "data:image/png;base64,BBBB", "brighter"))
assert result["reason"] == "unavailable"
+515
View File
@@ -0,0 +1,515 @@
"""User-supplied IMAP/SMTP hosts must pass the outbound host guard.
Regression: POST /api/email/accounts/test opened raw ``imaplib`` / ``smtplib``
connections to whatever host:port the caller supplied, and the account save
routes persisted arbitrary hosts for the background pollers to contact. Both
are reachable by any authenticated user (``require_user`` / ``require_owner``,
no admin gate), so a non-admin could port-scan loopback, the LAN and the cloud
metadata range through the server and read the first 200 chars of the banner
back out of the error message. THREAT_MODEL.md lists email as admin-only.
Policy (see routes/email_routes.py:_check_mail_host): link-local (metadata),
multicast, reserved and unspecified targets are always rejected; loopback and
private ranges are rejected for non-admin owners or when
EMAIL_BLOCK_PRIVATE_IPS=true, and stay allowed for admins (local-first: a LAN
Dovecot is a normal setup). Unresolved names are rejected before persistence;
live connections also validate and pin their own resolved addresses.
"""
import socket
import unittest.mock as mock
import pytest
from src.url_safety import check_outbound_host, connect_outbound_tcp, OutboundAddressBlocked
# ── helper policy table ──────────────────────────────────────────────────────
@pytest.mark.parametrize("host", [
"169.254.169.254",
"[::ffff:169.254.169.254]",
"::ffff:169.254.169.254",
"fe80::1",
"0.0.0.0",
"224.0.0.1",
"100.100.100.200",
"[100.100.100.200]",
])
def test_always_blocked_addresses(host):
ok, reason = check_outbound_host(host)
assert ok is False, (host, reason)
ok, reason = check_outbound_host(host, block_private=False)
assert ok is False
@pytest.mark.parametrize("host", [
"127.0.0.1",
"::1",
"[::1]",
"10.0.0.5",
"192.168.1.10",
"100.64.1.1",
"localhost",
"LOCALHOST.",
"mail.localhost",
"ip6-localhost",
])
def test_private_and_loopback_gated_by_block_private(host):
ok, _ = check_outbound_host(host, block_private=True)
assert ok is False, host
ok, _ = check_outbound_host(host, block_private=False)
assert ok is True, host
@pytest.mark.parametrize("host", ["host:993", "http://mail.example.com", "user@mail.example.com", "mail.example.com/", "a b", "", " "])
def test_non_bare_hosts_rejected(host):
ok, _ = check_outbound_host(host, resolver=lambda h: ["93.184.216.34"])
assert ok is False, host
def test_nonstring_rejected():
assert check_outbound_host(None)[0] is False # type: ignore[arg-type]
assert check_outbound_host(123)[0] is False # type: ignore[arg-type]
def test_resolved_name_is_classified():
ok, reason = check_outbound_host("metadata.google.internal", resolver=lambda h: ["169.254.169.254"])
assert ok is False and "link-local" in reason
ok, _ = check_outbound_host("mail.corp", block_private=True, resolver=lambda h: ["10.1.2.3"])
assert ok is False
ok, _ = check_outbound_host("mail.corp", block_private=False, resolver=lambda h: ["10.1.2.3"])
assert ok is True
ok, _ = check_outbound_host("imap.example.org", block_private=True, resolver=lambda h: ["93.184.216.34"])
assert ok is True
def test_unresolved_policy():
def _nx(h):
raise OSError("Name or service not known")
assert check_outbound_host("nope.invalid", resolver=_nx)[0] is False
assert check_outbound_host("nope.invalid", resolver=lambda h: [])[0] is False
# ── route wiring ─────────────────────────────────────────────────────────────
def _route(router, path, method):
for r in router.routes:
if r.path == path and method in getattr(r, "methods", set()):
return r.endpoint
raise AssertionError(f"route not found: {method} {path}")
def _make_orm_db():
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from core.database import Base
engine = create_engine("sqlite:///:memory:", connect_args={"check_same_thread": False})
Base.metadata.create_all(engine)
Factory = sessionmaker(bind=engine)
return Factory
def _set_admin(monkeypatch, is_admin: bool):
monkeypatch.setattr("src.tool_security.owner_is_admin_or_single_user", lambda owner: is_admin)
monkeypatch.delenv("EMAIL_BLOCK_PRIVATE_IPS", raising=False)
monkeypatch.delenv("EMAIL_ALLOW_PRIVATE_IPS", raising=False)
class _Req:
def __init__(self, body):
self._body = body
async def json(self):
return self._body
@pytest.mark.asyncio
async def test_test_connection_rejects_loopback_for_non_admin_before_any_socket(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {
"imap_host": "127.0.0.1", "imap_port": 11434, "imap_user": "x", "imap_password": "x",
"imap_starttls": False,
"smtp_host": "169.254.169.254", "smtp_port": 80, "smtp_security": "none",
}
with mock.patch("socket.socket") as create_socket:
result = await test_conn(req=_Req(body), owner="regular-user")
assert result["ok"] is False
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
assert ("Rejected SMTP host" in result["smtp"]["error"] or "server address is not allowed" in result["smtp"]["error"])
create_socket.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_metadata_ip_rejected_even_for_admin(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {"imap_host": "169.254.169.254", "imap_port": 80, "imap_user": "x", "imap_password": "x", "imap_starttls": True}
with mock.patch("socket.socket") as create_socket:
result = await test_conn(req=_Req(body), owner="admin")
assert result["ok"] is False
assert ("link-local" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
create_socket.assert_not_called()
@pytest.mark.asyncio
async def test_test_connection_admin_may_use_loopback(monkeypatch):
"""Local-first: an admin's Dovecot on localhost keeps working."""
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {"imap_host": "127.0.0.1", "imap_port": 31143, "imap_user": "x", "imap_password": "x", "imap_starttls": False}
with mock.patch("routes.email_routes._open_imap_connection", return_value=mock.MagicMock()) as imap_open:
result = await test_conn(req=_Req(body), owner="admin")
assert result["imap"]["ok"] is True
imap_open.assert_called_once()
@pytest.mark.asyncio
async def test_create_account_rejects_metadata_host_before_db(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
create = _route(router, "/api/email/accounts", "POST")
def _no_db():
raise AssertionError("DB must not be touched for a rejected host")
with mock.patch("core.database.SessionLocal", _no_db):
result = await create(
{"name": "Backup", "imap_host": "169.254.169.254", "imap_user": "u", "imap_password": "p"},
owner="admin",
)
assert result["ok"] is False
assert "Rejected IMAP host" in result["error"]
@pytest.mark.asyncio
async def test_create_account_rejects_private_host_for_non_admin(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
create = _route(router, "/api/email/accounts", "POST")
with mock.patch("core.database.SessionLocal", _make_orm_db()):
result = await create(
{"name": "Scan", "imap_host": "93.184.216.34", "smtp_host": "10.0.0.5", "imap_user": "u", "imap_password": "p"},
owner="regular-user",
)
assert result["ok"] is False
assert "Rejected SMTP host" in result["error"]
@pytest.mark.asyncio
async def test_create_account_admin_private_host_allowed_unless_locked_down(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, True)
router = email_routes.setup_email_routes()
create = _route(router, "/api/email/accounts", "POST")
payload = {"name": "LAN", "imap_host": "192.168.1.10", "imap_user": "u", "imap_password": "p"}
with mock.patch("core.database.SessionLocal", _make_orm_db()):
result = await create(dict(payload), owner="admin")
assert result["ok"] is True
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
with mock.patch("core.database.SessionLocal", _make_orm_db()):
result = await create(dict(payload), owner="admin")
assert result["ok"] is False
assert "Rejected IMAP host" in result["error"]
@pytest.mark.asyncio
async def test_update_account_rejects_bad_host_and_keeps_row(monkeypatch):
import routes.email_routes as email_routes
from core.database import EmailAccount
_set_admin(monkeypatch, False)
Factory = _make_orm_db()
db = Factory()
db.add(EmailAccount(id="acct-1", owner="regular-user", name="Mine", imap_host="imap.gmail.com", imap_port=993))
db.commit()
db.close()
router = email_routes.setup_email_routes()
update = _route(router, "/api/email/accounts/{account_id}", "PUT")
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._assert_owns_account", lambda account_id, owner: None):
result = await update("acct-1", {"imap_host": "127.0.0.1", "imap_port": 8000}, owner="regular-user")
assert result["ok"] is False
assert "Rejected IMAP host" in result["error"]
db = Factory()
try:
assert db.get(EmailAccount, "acct-1").imap_host == "imap.gmail.com"
finally:
db.close()
@pytest.mark.asyncio
async def test_update_config_rejects_bad_host(monkeypatch):
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
update_cfg = _route(router, "/api/email/config", "PUT")
def _no_db():
raise AssertionError("DB must not be touched for a rejected host")
with mock.patch("core.database.SessionLocal", _no_db), \
mock.patch("routes.email_routes._load_settings", _no_db):
result = await update_cfg({"smtp_host": "10.0.0.1", "smtp_port": 6379}, account_id=None, owner="regular-user")
assert result["ok"] is False
assert "Rejected SMTP host" in result["error"]
# ── adversarial runtime & connection-time tests ─────────────────────────────
def test_connect_outbound_tcp_blocks_dns_rebinding():
with mock.patch("socket.getaddrinfo", return_value=[(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]):
with pytest.raises(OutboundAddressBlocked) as exc_info:
connect_outbound_tcp("rebind.example.org", 993, block_private=False)
assert "link-local" in str(exc_info.value)
def test_connect_outbound_tcp_multiple_ips_fails_closed():
mixed_addrs = [
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993)),
(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("10.0.0.1", 993)),
]
with mock.patch("socket.getaddrinfo", return_value=mixed_addrs):
with pytest.raises(OutboundAddressBlocked) as exc_info:
connect_outbound_tcp("dual.example.org", 993, block_private=True)
assert "private/shared/loopback" in str(exc_info.value)
@pytest.mark.asyncio
async def test_test_connection_endpoint_dns_rebinding_blocked(monkeypatch):
"""If DNS changes between save-check and connection, connect-time validation stops it."""
import routes.email_routes as email_routes
_set_admin(monkeypatch, False)
router = email_routes.setup_email_routes()
test_conn = _route(router, "/api/email/accounts/test", "POST")
body = {
"imap_host": "rebind.example.org", "imap_port": 993, "imap_user": "u", "imap_password": "p",
"imap_starttls": False,
}
rebinding_addrs = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("127.0.0.1", 993))]
with mock.patch("socket.getaddrinfo", return_value=rebinding_addrs), \
mock.patch("socket.socket") as create_socket:
result = await test_conn(req=_Req(body), owner="regular-user")
assert result["ok"] is False
assert ("Rejected IMAP host" in result["imap"]["error"] or "server address is not allowed" in result["imap"]["error"])
create_socket.assert_not_called()
@pytest.mark.asyncio
async def test_runtime_imap_connect_blocks_preexisting_db_row(monkeypatch):
"""A database row with private host (preexisting, restored, or DB-injected) fails closed at runtime."""
import routes.email_helpers as email_helpers
from core.database import EmailAccount
_set_admin(monkeypatch, False)
Factory = _make_orm_db()
db = Factory()
db.add(EmailAccount(id="acct-malicious", owner="regular-user", name="Evil", imap_host="127.0.0.1", imap_port=993, imap_user="u", imap_password="p"))
db.commit()
db.close()
with mock.patch("core.database.SessionLocal", Factory):
with pytest.raises(OutboundAddressBlocked) as exc_info:
email_helpers._imap_connect("acct-malicious", owner="regular-user")
assert "private/shared/loopback" in str(exc_info.value)
def test_runtime_send_smtp_message_blocks_metadata_host(monkeypatch):
"""_send_smtp_message fails closed on cloud metadata destination."""
import routes.email_helpers as email_helpers
_set_admin(monkeypatch, True)
cfg = {
"smtp_host": "169.254.169.254",
"smtp_port": 25,
"owner": "admin",
}
with pytest.raises(OutboundAddressBlocked) as exc_info:
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
assert "link-local" in str(exc_info.value)
def test_ipv6_loopback_allowed_for_admin_blocked_for_non_admin():
mock_sock = mock.MagicMock()
with mock.patch("socket.socket", return_value=mock_sock):
sock = connect_outbound_tcp("::1", 993, block_private=False)
assert sock is mock_sock
mock_sock.connect.assert_called_with(("::1", 993, 0, 0))
with pytest.raises(OutboundAddressBlocked) as exc_info:
connect_outbound_tcp("::1", 993, block_private=True)
assert "private/shared/loopback" in str(exc_info.value)
def test_email_block_private_ips_env_overrides_admin(monkeypatch):
import routes.email_helpers as email_helpers
_set_admin(monkeypatch, True)
monkeypatch.setenv("EMAIL_BLOCK_PRIVATE_IPS", "true")
cfg = {
"smtp_host": "127.0.0.1",
"smtp_port": 25,
"owner": "admin",
}
with pytest.raises(OutboundAddressBlocked) as exc_info:
email_helpers._send_smtp_message(cfg, "from@example.com", ["to@example.com"], b"data")
assert "private/shared/loopback" in str(exc_info.value)
def test_tls_sni_preserved_during_safe_connection():
import routes.email_helpers as email_helpers
mock_sock = mock.MagicMock()
mock_ssl_sock = mock.MagicMock()
mock_ctx = mock.MagicMock()
mock_ctx.wrap_socket.return_value = mock_ssl_sock
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
imap = email_helpers._PolicyIMAP4_SSL.__new__(email_helpers._PolicyIMAP4_SSL)
imap._block_private = False
imap.host = "imap.secure.example.com"
imap.port = 993
imap.ssl_context = mock_ctx
res = imap._create_socket(10)
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="imap.secure.example.com")
assert res is mock_ssl_sock
mock_smtp_sock = mock.MagicMock()
mock_ctx.wrap_socket.return_value = mock_smtp_sock
with mock.patch("routes.email_helpers.connect_outbound_tcp", return_value=mock_sock):
smtp = email_helpers._PolicySMTP_SSL.__new__(email_helpers._PolicySMTP_SSL)
smtp._block_private = False
smtp._host = "smtp.secure.example.com"
smtp.context = mock_ctx
smtp.source_address = None
res = smtp._get_socket("smtp.secure.example.com", 465, 10)
mock_ctx.wrap_socket.assert_called_with(mock_sock, server_hostname="smtp.secure.example.com")
assert res is mock_smtp_sock
@pytest.mark.asyncio
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
@pytest.mark.parametrize("field", ["imap_host", "smtp_host"])
@pytest.mark.parametrize("is_admin", [False, True])
@pytest.mark.parametrize("answer", ["error", "empty", "garbage", "none"])
async def test_save_rejects_unresolved_hosts_before_persistence(monkeypatch, route_name, field, is_admin, answer):
import routes.email_routes as routes
import src.url_safety as safety
_set_admin(monkeypatch, is_admin)
def resolve(host):
if answer == "error":
raise RuntimeError("resolver-internal-detail")
return {"empty": [], "garbage": ["not-an-ip"], "none": None}[answer]
def no_persistence(*args, **kwargs):
raise AssertionError("Rejected mail host reached persistence")
monkeypatch.setattr(safety, "_default_resolver", resolve)
monkeypatch.setattr("core.database.SessionLocal", no_persistence)
monkeypatch.setattr(routes, "_load_settings", no_persistence)
monkeypatch.setattr(routes, "_save_settings", no_persistence)
monkeypatch.setattr(routes, "_assert_owns_account", lambda *a: None)
router = routes.setup_email_routes()
payload = {"name": "Pending DNS", field: "pending.example.invalid"}
owner = "admin" if is_admin else "regular-user"
if route_name == "create":
result = await _route(router, "/api/email/accounts", "POST")(payload, owner=owner)
elif route_name == "update":
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner=owner)
else:
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner=owner)
assert result["ok"] is False
assert "does not resolve" in result["error"]
assert "resolver-internal-detail" not in result["error"]
assert "not-an-ip" not in result["error"]
def test_connect_pins_the_first_resolution_without_rebinding_lookup():
public = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("93.184.216.34", 993))]
metadata = [(socket.AF_INET, socket.SOCK_STREAM, 6, "", ("169.254.169.254", 993))]
resolver = mock.Mock(side_effect=[public, metadata])
with mock.patch("socket.socket") as create_socket:
sock = connect_outbound_tcp("rebind.example.org", 993, resolver=resolver, block_private=True)
resolver.assert_called_once_with("rebind.example.org", 993, 0, socket.SOCK_STREAM)
sock.connect.assert_called_once_with(("93.184.216.34", 993))
def test_resolver_type_error_propagates_without_alternate_signature_retry():
resolver = mock.Mock(side_effect=TypeError("resolver-internal-bug"))
with mock.patch("socket.socket") as create_socket:
with pytest.raises(TypeError, match="resolver-internal-bug"):
connect_outbound_tcp("mail.example.org", 993, resolver=resolver)
resolver.assert_called_once_with("mail.example.org", 993, 0, socket.SOCK_STREAM)
create_socket.assert_not_called()
@pytest.mark.parametrize("port", [None, 0, -1, 65536, True, 25.5, "not-a-port"])
def test_invalid_tcp_port_is_rejected_before_resolution(port):
resolver = mock.Mock()
with pytest.raises(ValueError):
connect_outbound_tcp("mail.example.org", port, resolver=resolver)
resolver.assert_not_called()
def test_empty_resolver_iterator_fails_closed():
assert check_outbound_host("empty.example.org", resolver=lambda host: iter(()))[0] is False
def test_shared_space_metadata_endpoint_is_identified_correctly():
ok, reason = check_outbound_host("100.100.100.200", block_private=False)
assert ok is False
assert "metadata endpoint blocked" in reason
assert "link-local" not in reason
def test_importing_mail_helpers_does_not_replace_stdlib_clients():
import subprocess
import sys
subprocess.run([sys.executable, "-c", """
import imaplib, smtplib
original = (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
import routes.email.email_helpers
assert original == (imaplib.IMAP4, imaplib.IMAP4_SSL, smtplib.SMTP, smtplib.SMTP_SSL)
"""], check=True, capture_output=True, text=True)
@pytest.mark.asyncio
@pytest.mark.parametrize("route_name", ["create", "update", "config"])
async def test_save_host_resolution_runs_off_the_event_loop(monkeypatch, route_name):
import threading
import routes.email_routes as routes
event_loop_thread = threading.get_ident()
def guard(*args):
assert threading.get_ident() != event_loop_thread
return "Host rejected"
monkeypatch.setattr(routes, "_check_mail_host", guard)
monkeypatch.setattr(routes, "_assert_owns_account", lambda *args: None)
router = routes.setup_email_routes()
payload = {"name": "Pending DNS", "imap_host": "pending.example.invalid"}
if route_name == "create":
result = await _route(router, "/api/email/accounts", "POST")(payload, owner="admin")
elif route_name == "update":
result = await _route(router, "/api/email/accounts/{account_id}", "PUT")("acct", payload, owner="admin")
else:
result = await _route(router, "/api/email/config", "PUT")(payload, account_id=None, owner="admin")
assert result == {"ok": False, "error": "Host rejected"}
+6 -6
View File
@@ -26,7 +26,7 @@ class _FakeSock:
class _FakeIMAP:
calls = []
def __init__(self, host, port, timeout=None):
def __init__(self, host, port, timeout=None, **_policy):
self.host = host
self.port = port
self.timeout = timeout
@@ -60,8 +60,8 @@ def test_open_imap_connection_uses_shared_timeout_for_implicit_ssl(monkeypatch):
import routes.email_helpers as helpers
_FakeIMAP.calls = []
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
conn = _open_imap_connection("imap.one.com", 993, starttls=False)
@@ -75,8 +75,8 @@ def test_open_imap_connection_supports_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeIMAP.calls = []
monkeypatch.setattr(helpers.imaplib, "IMAP4", _FakeIMAP)
monkeypatch.setattr(helpers.imaplib, "IMAP4_SSL", _FakeIMAPSSL)
monkeypatch.setattr(helpers, "_PolicyIMAP4", _FakeIMAP)
monkeypatch.setattr(helpers, "_PolicyIMAP4_SSL", _FakeIMAPSSL)
_open_imap_connection("imap.local", 143, starttls=True)
@@ -99,7 +99,7 @@ async def test_account_config_uses_shared_imap_timeout(monkeypatch):
def logout(self):
captured["logout"] = True
def fake_open(host, port, *, starttls, timeout):
def fake_open(host, port, *, starttls, timeout, **_policy):
captured["open"] = (host, port, starttls, timeout)
return _Conn()
+901 -6
View File
@@ -41,18 +41,18 @@ def test_email_bulk_read_unread_checks_backend_success_before_syncing_cache():
assert "_libCacheWriteBack()" in src
def test_email_bulk_export_attachments_is_ui_only_selected_context():
def test_email_bulk_exports_messages_and_attachments_from_selected_context():
frontend = email_library_source()
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
export_src = frontend[
frontend.index("async function _exportSelectedAttachments()"):
frontend.index("\n}\n\n// URL-suffix helper", frontend.index("async function _exportSelectedAttachments()")) + 3
]
export_src = _function_source("_exportSelectedBundle")
payload_src = _function_source("_selectedEmailExportPayload")
assert "Export Emails" in frontend
assert "Email file (.eml)" in frontend
assert "_exportSelectedEmailPdf" in frontend
assert "Export Attachments" in frontend
assert "All in view" in frontend
assert "/api/email/attachments-download-bulk" in export_src
assert "${isEmailExport ? 'messages' : 'attachments'}-download-bulk" in export_src
assert "method: 'POST'" in export_src
assert "res.blob()" in export_src
assert "account_id: String(em.account_id || state._libAccountId || '')" in payload_src
@@ -63,6 +63,96 @@ def test_email_bulk_export_attachments_is_ui_only_selected_context():
assert "_fixture_email_read(uid, folder, owner)" in backend
assert "_is_likely_signature_image_attachment(att)" in backend
assert '@router.post("/messages-download-bulk")' in backend
assert '"(UID RFC822)"' in backend
assert "msg.as_bytes(policy=email_policy.SMTP)" in backend
assert 'media_type="message/rfc822"' in backend
assert '"X-Exported-Emails"' in backend
def test_email_print_pdf_export_reads_without_marking_read_and_sanitizes_body():
"""Print / save PDF export must use the browser print dialog (SAN-157).
The removed html2pdf library must not be loaded or referenced. Instead
the export uses a sandboxed iframe and window.print(), matching the
established pattern in document.js.
"""
frontend = email_library_source()
print_src = _function_source("_printSelectedEmails")
menu_src = js_function_source("_showBulkActionsMenu", frontend)
assert "mark_seen=false&full=true" in print_src
assert "_safeRenderEmailBody(data)" in print_src
assert "new DOMParser()" in print_src
assert "up to 20 emails" in print_src
assert "Print / save PDF" in menu_src
# SAN-157: must NOT reference the removed html2pdf library.
assert "html2pdf" not in print_src
assert "html2pdf" not in frontend
# Must use the browser print pattern with a sandboxed iframe.
assert "contentWindow.print()" in print_src
assert "sandbox" in print_src
assert "allow-same-origin" in print_src
assert "allow-modals" in print_src
assert "afterprint" in print_src
assert "email-browser-print-frame" in print_src
def test_email_eml_export_bounds_total_size():
"""The /messages-download-bulk endpoint must enforce a total size cap
to prevent unbounded memory growth when exporting many large messages."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "max_total_bytes" in backend
assert "total_bytes" in backend
assert "Export too large" in backend
assert "status_code=413" in backend
def test_email_eml_export_validates_selection_count():
"""The /messages-download-bulk endpoint must reject oversized selections."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert 'len(messages) > 250' in backend
assert '"Too many emails selected"' in backend
def test_email_eml_export_rejects_non_numeric_remote_uids():
"""Non-numeric UIDs must not be passed to IMAP UID FETCH."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
# After fixture lookup fails, non-numeric UIDs are skipped.
assert 're.fullmatch(r"\\d+", uid)' in backend
def test_email_eml_export_handles_partial_batch_failures():
"""When an IMAP fetch batch returns partial results, exported/skipped
accounting must remain correct."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
# The skipped counter increments for UIDs not found in the batch response.
assert "skipped += len(batch) - len(found)" in backend
# Whole-connection failures count remaining unprocessed rows as skipped.
assert "skipped += remaining_rows" in backend
assert '"X-Exported-Emails"' in backend
assert '"X-Skipped-Emails"' in backend
def test_email_eml_export_enforces_account_ownership():
"""The /messages-download-bulk endpoint must assert account ownership."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "_assert_owns_account(account_id, owner)" in backend
def test_email_eml_export_deduplicates_selection():
"""Duplicate items in selection must be skipped to avoid duplicate files."""
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
assert "seen_items" in backend
def test_fixture_tag_filter_supports_receipt_tag_flow():
backend = _EMAIL_ROUTES.read_text(encoding="utf-8")
@@ -171,3 +261,808 @@ def test_email_compose_can_attach_gallery_images():
assert "GalleryImage" in backend
assert "_gallery_image_path(img.filename)" in backend
assert "_stage_compose_file" in backend
class _MockImapConn:
def __init__(self, messages_by_uid: dict[str, bytes] | None = None, fetch_exc: Exception | None = None, fetch_cb=None):
self.messages = messages_by_uid or {}
self.fetch_exc = fetch_exc
self.fetch_cb = fetch_cb
self.fetch_calls: list = []
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
if command == "FETCH":
self.fetch_calls.append(args)
if self.fetch_exc is not None:
raise self.fetch_exc
if self.fetch_cb:
return self.fetch_cb(args)
uid_set_b = args[0]
uid_set = uid_set_b.decode() if isinstance(uid_set_b, bytes) else str(uid_set_b)
uids = [u.strip() for u in uid_set.split(",") if u.strip()]
fetch_cmd = str(args[1]) if len(args) > 1 else ""
res = []
if "RFC822.SIZE" in fetch_cmd:
for u in uids:
if u in self.messages:
meta = f"1 (UID {u} RFC822.SIZE {len(self.messages[u])})".encode()
res.append(meta)
return "OK", res
for u in uids:
if u in self.messages:
meta = f"1 (UID {u} RFC822 {{{len(self.messages[u])}}}".encode()
res.append((meta, self.messages[u]))
res.append(b")")
return "OK", res
return "NO", []
def _create_test_app(monkeypatch, mailbox: _MockImapConn | None = None, assert_owns_fn=None):
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
conn = mailbox or _MockImapConn()
@contextmanager
def mock_imap(account_id=None, owner=""):
yield conn
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
if assert_owns_fn:
monkeypatch.setattr(routes, "_assert_owns_account", assert_owns_fn)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
return TestClient(app)
def test_messages_download_bulk_validates_empty_and_oversized_payload(monkeypatch):
client = _create_test_app(monkeypatch)
res_empty = client.post("/api/email/messages-download-bulk", json={"messages": []})
assert res_empty.status_code == 400
assert "No emails selected" in res_empty.text
oversized = [{"uid": str(i)} for i in range(251)]
res_over = client.post("/api/email/messages-download-bulk", json={"messages": oversized})
assert res_over.status_code == 400
assert "Too many emails selected" in res_over.text
def test_messages_download_bulk_single_message_eml_export(monkeypatch):
raw_rfc822 = b"From: sender@example.com\r\nTo: test-user\r\nSubject: Report\r\n\r\nReport content"
mailbox = _MockImapConn({"101": raw_rfc822})
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101", "subject": "Quarterly Report", "date": "2026-10-20"}]
})
assert res.status_code == 200
assert res.headers["content-type"].startswith("message/rfc822")
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "0"
assert "email.eml" in res.headers["content-disposition"]
assert res.content == raw_rfc822
def test_messages_download_bulk_multi_message_zip_export(monkeypatch):
import io
import zipfile
msg1 = b"From: a@example.com\r\nSubject: First\r\n\r\nOne"
msg2 = b"From: b@example.com\r\nSubject: Second\r\n\r\nTwo"
mailbox = _MockImapConn({"101": msg1, "102": msg2})
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "subject": "First Msg", "date": "2026-10-18"},
{"uid": "102", "subject": "Second Msg", "date": "2026-10-19"},
]
})
assert res.status_code == 200
assert res.headers["content-type"] == "application/zip"
assert res.headers["x-exported-emails"] == "2"
assert res.headers["x-skipped-emails"] == "0"
zf = zipfile.ZipFile(io.BytesIO(res.content))
namelist = zf.namelist()
assert len(namelist) == 2
assert any("101.eml" in name for name in namelist)
assert any("102.eml" in name for name in namelist)
def test_messages_download_bulk_partial_failure_and_duplicate_dedup(monkeypatch):
import io
import zipfile
msg1 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nExists body"
# UID 101 exists, UID 999 does not exist in mailbox
mailbox = _MockImapConn({"101": msg1})
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "subject": "Exists"},
{"uid": "999", "subject": "Missing"},
{"uid": "101", "subject": "Duplicate"}, # duplicate must be skipped
]
})
assert res.status_code == 200
assert res.headers["content-type"] == "application/zip"
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "2"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 1
def test_messages_download_bulk_enforces_account_ownership_guard(monkeypatch):
from fastapi import HTTPException
def mock_assert_owns(account_id, owner):
if account_id == "other-tenant-acct":
raise HTTPException(status_code=404, detail="Account not found")
client = _create_test_app(monkeypatch, assert_owns_fn=mock_assert_owns)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101", "account_id": "other-tenant-acct"}]
})
assert res.status_code == 404
assert "Account not found" in res.text
def test_messages_download_bulk_mid_batch_failure_accounting(monkeypatch):
"""When an exception occurs mid-batch after partial progress, already-exported
records must not be double-counted as skipped."""
class MidBatchFailImap:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
if "RFC822.SIZE" in fetch_cmd:
return "OK", [f"{i} (UID {i} RFC822.SIZE 100)".encode() for i in range(1, 11)]
records = []
for i in range(1, 5):
records.append((f"{i} (UID {i} RFC822 {{{10}}}".encode(), f"Subject: Msg {i}\r\n\r\nBody {i}".encode()))
class BadRecord:
def __bytes__(self):
raise RuntimeError("Corrupt memory buffer")
records.append((b"5 (UID 5 RFC822 {10}", BadRecord()))
return "OK", records
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
@contextmanager
def mock_imap(account_id=None, owner=""):
yield MidBatchFailImap()
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
client = TestClient(app)
rows = [{"uid": str(i)} for i in range(1, 11)]
res = client.post("/api/email/messages-download-bulk", json={"messages": rows})
assert res.status_code == 200
exp = int(res.headers.get("x-exported-emails"))
skp = int(res.headers.get("x-skipped-emails"))
assert exp == 4
assert skp == 6
assert exp + skp == 10
def test_messages_download_bulk_size_cap_413_boundary(monkeypatch):
"""Cumulative export size exceeding max_total_bytes returns HTTP 413."""
class LargeMessageImap:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
raw_uids = args[0] if isinstance(args[0], str) else args[0].decode()
if "(UID RFC822.SIZE)" in str(args):
records = []
for u in raw_uids.split(","):
records.append(f"{u} (UID {u} RFC822.SIZE 10485760)".encode())
return "OK", records
records = []
big_chunk = b"X" * (10 * 1024 * 1024)
for u in raw_uids.split(","):
records.append((f"{u} (UID {u} RFC822".encode(), big_chunk))
return "OK", records
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
@contextmanager
def mock_imap(account_id=None, owner=""):
yield LargeMessageImap()
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
client = TestClient(app)
# 52 messages of 10 MB = 520 MB > 512 MiB limit
payload_52 = [{"uid": str(i + 1), "subject": f"Big {i}"} for i in range(52)]
res = client.post("/api/email/messages-download-bulk", json={"messages": payload_52})
assert res.status_code == 413
assert res.json()["detail"] == "Export too large"
def test_messages_download_bulk_per_message_limit_413(monkeypatch):
"""A single oversized message (>64 MiB) triggers HTTP 413."""
class HugeSingleMsgImap:
def select(self, folder, readonly=True):
return "OK", [b"1"]
def uid(self, command, *args):
return "OK", [b"1 (UID 101 RFC822.SIZE 73400320)"]
from contextlib import contextmanager
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.email import email_routes as routes
@contextmanager
def mock_imap(account_id=None, owner=""):
yield HugeSingleMsgImap()
monkeypatch.setattr(routes, "_start_poller", lambda: None)
monkeypatch.setattr(routes, "_POOL_HOOKS", {})
monkeypatch.setattr(routes, "_imap", mock_imap)
app = FastAPI()
app.include_router(routes.setup_email_routes())
app.dependency_overrides[routes.require_owner] = lambda: "test-user"
client = TestClient(app)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101", "subject": "Huge Email"}]
})
assert res.status_code == 413
assert res.json()["detail"] == "Export too large"
def test_messages_download_bulk_account_ownership_memoization(monkeypatch):
"""Account ownership validation is memoized per distinct account_id to eliminate N+1 DB queries."""
owns_calls = []
def counting_assert_owns(account_id, owner):
owns_calls.append(account_id)
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
msgs = {str(i): raw_rfc822 for i in range(1, 26)}
mailbox = _MockImapConn(msgs)
client = _create_test_app(monkeypatch, mailbox=mailbox, assert_owns_fn=counting_assert_owns)
# 25 messages all referencing the same account_id
payload = [{"uid": str(i), "account_id": "acc-shared"} for i in range(1, 26)]
res = client.post("/api/email/messages-download-bulk", json={"messages": payload})
assert res.status_code == 200
assert len(owns_calls) == 1
def test_messages_download_bulk_corrupt_zip_fails_closed(monkeypatch):
"""If ZIP construction fails (e.g. disk error), fail closed with 500 instead of returning corrupt archive."""
import zipfile
raw_rfc822 = b"From: sender@example.com\r\nSubject: Test\r\n\r\nBody"
mailbox = _MockImapConn({"101": raw_rfc822, "102": raw_rfc822})
orig_writestr = zipfile.ZipFile.writestr
def failing_writestr(self, zinfo_or_arcname, data=None, *args, **kwargs):
raise OSError("Disk write failed: No space left on device")
monkeypatch.setattr(zipfile.ZipFile, "writestr", failing_writestr)
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res.status_code == 500
assert "Failed to create export archive" in res.json()["detail"]
def test_messages_download_bulk_preflight_no_fails_safe_no_body_fetch(monkeypatch):
"""When preflight returns NO, size safety is unknown: fail safely (502) and do not fetch bodies."""
mailbox = _MockImapConn({"101": b"dummy", "102": b"dummy"})
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
assert res.status_code == 502
assert "Unable to verify email size safety" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_empty_missing_mail(monkeypatch):
"""When preflight returns empty data, all requested messages are missing on server: returns 404 with no body fetch."""
mailbox = _MockImapConn() # no messages
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}, {"uid": "102"}]})
assert res.status_code == 404
assert "No selected emails could be exported" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_malformed_fails_safe_no_body_fetch(monkeypatch):
"""When preflight returns records without RFC822.SIZE, fail safely (502) and do not fetch bodies."""
mailbox = _MockImapConn({"101": b"dummy"})
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 FLAGS (\\Seen))"]) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res.status_code == 502
assert "Invalid email size metadata from server" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_inconsistent_fails_safe_no_body_fetch(monkeypatch):
"""When preflight returns negative or corrupted size values, fail safely (502) and do not fetch bodies."""
mailbox = _MockImapConn({"101": b"dummy"})
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 RFC822.SIZE -50)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res.status_code == 502
assert "Invalid email size metadata from server" in res.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_preflight_partial_missing_omitted_from_body_fetch(monkeypatch):
"""When some requested UIDs do not exist on the server, omit them from body fetch and export verified items."""
msg1 = b"From: a@example.com\r\nSubject: Only One\r\n\r\nBody"
mailbox = _MockImapConn({"101": msg1}) # 102 does not exist on server
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "1"
# Verify body fetch was only requested for 101, NOT 102
body_fetches = [call for call in mailbox.fetch_calls if "(UID RFC822)" in str(call)]
assert len(body_fetches) == 1
requested_uids = body_fetches[0][0].decode() if isinstance(body_fetches[0][0], bytes) else str(body_fetches[0][0])
assert requested_uids == "101"
assert "102" not in requested_uids
def test_messages_download_bulk_duplicate_imap_records_rejected(monkeypatch):
"""Duplicate IMAP FETCH response records for the same UID must be ignored.
They must not produce duplicate archive entries or corrupt accounting."""
import io
import zipfile
msg1 = b"From: a@example.com\r\nSubject: Unique\r\n\r\nBody 1"
mailbox = _MockImapConn({"101": msg1})
def custom_fetch(args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
if "RFC822.SIZE" in fetch_cmd:
return "OK", [b"1 (UID 101 RFC822.SIZE 100)", b"2 (UID 102 RFC822.SIZE 100)"]
# In body fetch, server sends duplicate records for 101, and nothing for 102
records = [
(b"1 (UID 101 RFC822 {50}", msg1),
b")",
(b"1 (UID 101 RFC822 {50}", msg1), # DUPLICATE RECORD
b")",
]
return "OK", records
mailbox.fetch_cb = custom_fetch
client = _create_test_app(monkeypatch, mailbox=mailbox)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "1"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 1
assert "101.eml" in zf.namelist()[0]
def test_messages_download_bulk_single_message_preflight_failure_no_body_fetch(monkeypatch):
"""In single message mode, bad preflight or missing mail fails safely without body fetch."""
mailbox = _MockImapConn({"101": b"dummy"})
# Case A: preflight NO -> 502
mailbox.fetch_cb = lambda args: ("NO", []) if "RFC822.SIZE" in str(args) else ("OK", [])
client = _create_test_app(monkeypatch, mailbox=mailbox)
res_no = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res_no.status_code == 502
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
# Case B: missing mail -> 404
mailbox.fetch_calls.clear()
mailbox.fetch_cb = lambda args: ("OK", []) if "RFC822.SIZE" in str(args) else ("OK", [])
res_miss = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res_miss.status_code == 404
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
# Case C: malformed metadata -> 502
mailbox.fetch_calls.clear()
mailbox.fetch_cb = lambda args: ("OK", [b"1 (UID 101 NO_SIZE)"]) if "RFC822.SIZE" in str(args) else ("OK", [])
res_mal = client.post("/api/email/messages-download-bulk", json={"messages": [{"uid": "101"}]})
assert res_mal.status_code == 502
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_spooled_stream_lifecycle():
"""Verify that _iter_spooled closes the underlying SpooledTemporaryFile on completion and GeneratorExit."""
import tempfile
from routes.email.email_routes import setup_email_routes
# Test normal completion
f = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
f.write(b"Hello world payload")
f.seek(0)
# Re-create generator helper pattern
def _iter(file_obj, chunk_size=64):
try:
while True:
chunk = file_obj.read(chunk_size)
if not chunk:
break
yield chunk
finally:
file_obj.close()
gen = _iter(f)
chunks = list(gen)
assert b"".join(chunks) == b"Hello world payload"
assert f.closed
# Test early disconnect / cancellation (GeneratorExit)
f2 = tempfile.SpooledTemporaryFile(max_size=1024, mode="w+b")
f2.write(b"Chunk1Chunk2Chunk3")
f2.seek(0)
gen2 = _iter(f2, chunk_size=6)
next(gen2)
assert not f2.closed
gen2.close() # Simulates client disconnect / generator cancellation
assert f2.closed
def test_uid_and_size_from_fetch_meta_case_insensitivity_and_validation():
"""Unit test: _uid_from_fetch_meta and _size_from_fetch_meta case insensitivity and boundary validation."""
from routes.email.email_routes import _uid_from_fetch_meta, _size_from_fetch_meta
# 1. Standard uppercase UID and RFC822.SIZE
meta_upper = b"1 (UID 101 RFC822.SIZE 1234)"
assert _uid_from_fetch_meta(meta_upper) == "101"
assert _size_from_fetch_meta(meta_upper) == 1234
# 2. Lowercase uid and rfc822.size
meta_lower = b"1 (uid 202 rfc822.size 5678)"
assert _uid_from_fetch_meta(meta_lower) == "202"
assert _size_from_fetch_meta(meta_lower) == 5678
# 3. Mixed-case combinations
combos = [
(b"1 (Uid 303 Rfc822.Size 1000)", "303", 1000),
(b"1 (uID 404 RFC822.size 2000)", "404", 2000),
(b"1 (UID 505 rfc822.size 3000)", "505", 3000),
(b"1 (uid 606 RFC822.SIZE 4000)", "606", 4000),
(b"1 (uId 707 Rfc822.SIZE 5000)", "707", 5000),
(b"1 (FLAGS (\\Seen) uid 808 rfc822.Size 6000)", "808", 6000),
]
for meta_bytes, exp_uid, exp_sz in combos:
assert _uid_from_fetch_meta(meta_bytes) == exp_uid
assert _size_from_fetch_meta(meta_bytes) == exp_sz
# 4. Malformed metadata cases
assert _uid_from_fetch_meta(b"1 (rfc822.size 1234)") == ""
assert _size_from_fetch_meta(b"1 (uid 101 flags (\\seen))") is None
assert _uid_from_fetch_meta(b"1 (uid abc rfc822.size 1234)") == ""
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size invalid)") is None
assert _size_from_fetch_meta(b"1 (uid 101 rfc822.size -50)") is None
# 5. Boundary integrity (must not match substrings like UUID, GUID, NOT_RFC822.SIZE)
assert _uid_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") == ""
assert _size_from_fetch_meta(b"1 (UUID 101 GUID 202 NOT_RFC822.SIZE 1234)") is None
assert _uid_from_fetch_meta(b"1 (uid101 rfc822.size1234)") == ""
assert _size_from_fetch_meta(b"1 (uid101 rfc822.size1234)") is None
assert _uid_from_fetch_meta(b"") == ""
assert _size_from_fetch_meta(b"") is None
def test_messages_download_bulk_case_insensitive_imap_atoms(monkeypatch):
"""End-to-end: Bulk and single-message export with lowercase and mixed-case IMAP atoms."""
import io
import zipfile
msg101 = b"From: a@example.com\r\nSubject: Item 101\r\n\r\nBody 1"
msg102 = b"From: b@example.com\r\nSubject: Item 102\r\n\r\nBody 2"
mailbox = _MockImapConn({"101": msg101, "102": msg102})
def custom_fetch(args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
if "RFC822.SIZE" in fetch_cmd or "rfc822.size" in fetch_cmd.lower():
# Server returns lowercase/mixed-case atoms
return "OK", [
b"1 (uid 101 rfc822.size " + str(len(msg101)).encode() + b")",
b"2 (Uid 102 Rfc822.Size " + str(len(msg102)).encode() + b")",
]
# In body fetch, server returns mixed-case atoms
return "OK", [
(b"1 (uid 101 rfc822 {" + str(len(msg101)).encode(), msg101),
b")",
(b"2 (Uid 102 RFC822 {" + str(len(msg102)).encode(), msg102),
b")",
]
mailbox.fetch_cb = custom_fetch
client = _create_test_app(monkeypatch, mailbox=mailbox)
# 1. Bulk download (2 items) with lowercase / mixed-case IMAP responses
res_bulk = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}, {"uid": "102"}]
})
assert res_bulk.status_code == 200
assert res_bulk.headers["x-exported-emails"] == "2"
assert res_bulk.headers["x-skipped-emails"] == "0"
zf = zipfile.ZipFile(io.BytesIO(res_bulk.content))
assert len(zf.namelist()) == 2
# 2. Single message download fast path with lowercase IMAP response
mailbox.fetch_calls.clear()
res_single = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}]
})
assert res_single.status_code == 200
assert res_single.headers["content-type"].startswith("message/rfc822")
assert res_single.content == msg101
# 3. Lowercase malformed preflight fails safely with 502 and NO body fetch
mailbox.fetch_calls.clear()
mailbox.fetch_cb = lambda args: (
("OK", [b"1 (uid 101 rfc822.size NaN)"])
if "RFC822.SIZE" in str(args) or "rfc822.size" in str(args).lower()
else ("OK", [])
)
res_malformed = client.post("/api/email/messages-download-bulk", json={
"messages": [{"uid": "101"}]
})
assert res_malformed.status_code == 502
assert "Invalid email size metadata from server" in res_malformed.json()["detail"]
assert not any("(UID RFC822)" in str(call) for call in mailbox.fetch_calls)
def test_messages_download_bulk_multi_group_preflight_failure_propagates_502(monkeypatch):
"""A preflight safety failure in any group produces HTTP 502 for the entire request,
even when another group has already exported messages into the temporary ZIP.
Confirms temporary-file cleanup, zero body FETCH for the failed group, and both group orderings."""
import tempfile
msg101 = b"From: a@example.com\r\nSubject: Good\r\n\r\nBody 1"
spooled_instances: list[tempfile.SpooledTemporaryFile] = []
orig_spooled = tempfile.SpooledTemporaryFile
def tracking_spooled(*args, **kwargs):
inst = orig_spooled(*args, **kwargs)
spooled_instances.append(inst)
return inst
monkeypatch.setattr(tempfile, "SpooledTemporaryFile", tracking_spooled)
class MultiMailboxConn:
def __init__(self):
self.fetch_calls: list[tuple[str, tuple]] = []
self.current_folder = "INBOX"
def select(self, folder, readonly=True):
self.current_folder = str(folder)
return "OK", [b"1"]
def uid(self, command, *args):
self.fetch_calls.append((self.current_folder, args))
fetch_cmd = str(args[1]) if len(args) > 1 else ""
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
if "RFC822.SIZE" in fetch_cmd:
if "folder-fail" in self.current_folder:
return "NO", [] # Preflight failure for folder-fail
return "OK", [f"1 (UID {uid_set} RFC822.SIZE 100)".encode()]
# Body fetch
if "folder-fail" in self.current_folder:
return "NO", []
return "OK", [(f"1 (UID {uid_set} RFC822 {{100}}".encode(), msg101), b")"]
# 1. Successful group followed by failed preflight
conn1 = MultiMailboxConn()
client1 = _create_test_app(monkeypatch, mailbox=conn1)
spooled_instances.clear()
res1 = client1.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "folder": "folder-good"},
{"uid": "202", "folder": "folder-fail"},
]
})
assert res1.status_code == 502
assert "Unable to verify email size safety" in res1.json()["detail"]
# No RFC822 body FETCH for the failed group
failed_body_fetches1 = [
call for folder, call in conn1.fetch_calls
if "folder-fail" in folder and "(UID RFC822)" in str(call)
]
assert len(failed_body_fetches1) == 0
# Temporary file cleanup confirmed
assert len(spooled_instances) == 1
assert spooled_instances[0].closed
# 2. Failed preflight followed by successful group
conn2 = MultiMailboxConn()
client2 = _create_test_app(monkeypatch, mailbox=conn2)
spooled_instances.clear()
res2 = client2.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "202", "folder": "folder-fail"},
{"uid": "101", "folder": "folder-good"},
]
})
assert res2.status_code == 502
assert "Unable to verify email size safety" in res2.json()["detail"]
# No RFC822 body FETCH for the failed group
failed_body_fetches2 = [
call for folder, call in conn2.fetch_calls
if "folder-fail" in folder and "(UID RFC822)" in str(call)
]
assert len(failed_body_fetches2) == 0
# Temporary file cleanup confirmed
assert len(spooled_instances) == 1
assert spooled_instances[0].closed
def test_messages_download_bulk_multi_group_both_successful(monkeypatch):
"""When both groups succeed preflight and body fetch, return 200 with all messages in ZIP."""
import io
import zipfile
msg101 = b"From: a@example.com\r\nSubject: Item 1\r\n\r\nBody 1"
msg202 = b"From: b@example.com\r\nSubject: Item 2\r\n\r\nBody 2"
class MultiMailboxConn:
def __init__(self):
self.current_folder = "INBOX"
def select(self, folder, readonly=True):
self.current_folder = str(folder)
return "OK", [b"1"]
def uid(self, command, *args):
fetch_cmd = str(args[1]) if len(args) > 1 else ""
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
msg = msg101 if "101" in uid_set else msg202
if "RFC822.SIZE" in fetch_cmd:
return "OK", [f"1 (UID {uid_set} RFC822.SIZE {len(msg)})".encode()]
return "OK", [(f"1 (UID {uid_set} RFC822 {{{len(msg)}}}".encode(), msg), b")"]
conn = MultiMailboxConn()
client = _create_test_app(monkeypatch, mailbox=conn)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "folder": "folder-1"},
{"uid": "202", "folder": "folder-2"},
]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "2"
assert res.headers["x-skipped-emails"] == "0"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 2
def test_messages_download_bulk_multi_group_genuinely_missing_message(monkeypatch):
"""Genuinely missing messages (unreturned by preflight) do not cause 502;
they are skipped while verified messages in any group are successfully exported."""
import io
import zipfile
msg101 = b"From: a@example.com\r\nSubject: Exists\r\n\r\nBody"
class MissingMailboxConn:
def __init__(self):
self.fetch_calls: list = []
self.current_folder = "INBOX"
def select(self, folder, readonly=True):
self.current_folder = str(folder)
return "OK", [b"1"]
def uid(self, command, *args):
self.fetch_calls.append((self.current_folder, args))
fetch_cmd = str(args[1]) if len(args) > 1 else ""
uid_set = args[0].decode() if isinstance(args[0], bytes) else str(args[0])
if "RFC822.SIZE" in fetch_cmd:
# 101 exists on server; 999 is genuinely missing (unreported in size metadata)
res = []
if "101" in uid_set:
res.append(f"1 (UID 101 RFC822.SIZE {len(msg101)})".encode())
return "OK", res
# Body fetch only returns 101
return "OK", [(f"1 (UID 101 RFC822 {{{len(msg101)}}}".encode(), msg101), b")"]
conn = MissingMailboxConn()
client = _create_test_app(monkeypatch, mailbox=conn)
res = client.post("/api/email/messages-download-bulk", json={
"messages": [
{"uid": "101", "folder": "folder-1"},
{"uid": "999", "folder": "folder-1"},
]
})
assert res.status_code == 200
assert res.headers["x-exported-emails"] == "1"
assert res.headers["x-skipped-emails"] == "1"
zf = zipfile.ZipFile(io.BytesIO(res.content))
assert len(zf.namelist()) == 1
# Verify 999 was never requested in body fetch
body_fetches = [
call for folder, call in conn.fetch_calls
if "(UID RFC822)" in str(call)
]
assert len(body_fetches) == 1
assert "999" not in str(body_fetches[0])
def test_email_print_browser_behaviors():
"""Real Chromium DOM: placeholder stripping, button removal, and partial failure."""
import shutil
import subprocess
if not shutil.which('node'):
import pytest
pytest.skip('Node is required')
probe = subprocess.run(['node', '-e', "require.resolve('playwright')"], cwd=_REPO, capture_output=True)
if probe.returncode:
import pytest
pytest.skip('Playwright is required')
result = subprocess.run(
['node', str(_REPO / 'tests/test_email_library_print_browser.cjs')],
cwd=_REPO,
capture_output=True,
text=True,
timeout=60,
)
assert result.returncode == 0, result.stdout + result.stderr
assert "ALL_BROWSER_TESTS_PASSED" in result.stdout
+438
View File
@@ -0,0 +1,438 @@
/**
* Browser print regression tests for Email Library print and PDF export.
*
* Requirements:
* - Node.js (v20+)
* - Playwright (`npm ci` or `npm install -D @playwright/test`)
* - Chromium browser binary:
* - Standard Playwright install: `npx playwright install chromium`
* - Or custom executable via env vars:
* - `PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH=/path/to/chrome`
* - `CHROME_PATH=/path/to/chrome`
* - Or custom browsers cache dir via:
* - `PLAYWRIGHT_BROWSERS_PATH=/path/to/cache`
*/
const http = require('http');
const fs = require('fs');
const path = require('path');
const os = require('os');
let chromium;
try {
({ chromium } = require('playwright'));
} catch (err) {
if (err && err.code === 'MODULE_NOT_FOUND') {
console.error(
'Error: Playwright module not found.\n' +
'Please install dependencies by running:\n' +
' npm ci\n' +
'or ensure NODE_PATH contains the playwright package.'
);
process.exit(1);
}
throw err;
}
const ROOT = path.resolve(__dirname, '..');
// Discover available Chromium executable using standard Playwright resolution,
// custom environment variables, standard cache paths, or system binaries.
function findChromiumPath() {
// 1. Explicit environment variable overrides
if (process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH && fs.existsSync(process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH)) {
return process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH;
}
if (process.env.CHROME_PATH && fs.existsSync(process.env.CHROME_PATH)) {
return process.env.CHROME_PATH;
}
// 2. Playwright's configured/default executable path
try {
const defaultPath = chromium.executablePath();
if (defaultPath && fs.existsSync(defaultPath)) {
return defaultPath;
}
} catch (e) {
// chromium.executablePath() may throw if no default browser is installed
}
// 3. Inspect standard Playwright browser cache directories
const cacheDirs = [];
if (process.env.PLAYWRIGHT_BROWSERS_PATH) {
cacheDirs.push(process.env.PLAYWRIGHT_BROWSERS_PATH);
}
if (process.platform === 'win32') {
if (process.env.LOCALAPPDATA) {
cacheDirs.push(path.join(process.env.LOCALAPPDATA, 'ms-playwright'));
}
cacheDirs.push(path.join(os.homedir(), 'AppData', 'Local', 'ms-playwright'));
} else if (process.platform === 'darwin') {
cacheDirs.push(path.join(os.homedir(), 'Library', 'Caches', 'ms-playwright'));
} else {
cacheDirs.push(path.join(os.homedir(), '.cache', 'ms-playwright'));
}
for (const cacheDir of cacheDirs) {
if (!fs.existsSync(cacheDir)) continue;
try {
const entries = fs.readdirSync(cacheDir).sort().reverse();
for (const entry of entries) {
const entryPath = path.join(cacheDir, entry);
const candidates = [
path.join(entryPath, 'chrome-headless-shell-linux64', 'chrome-headless-shell'),
path.join(entryPath, 'chrome-linux64', 'chrome'),
path.join(entryPath, 'chrome-linux', 'chrome'),
path.join(entryPath, 'chrome-win64', 'chrome.exe'),
path.join(entryPath, 'chrome-win', 'chrome.exe'),
path.join(entryPath, 'chrome-mac', 'Chromium.app', 'Contents', 'MacOS', 'Chromium'),
];
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
}
} catch (e) {}
}
// 4. System browser fallbacks
const systemCandidates = process.platform === 'win32' ? [
path.join(process.env['PROGRAMFILES'] || 'C:\\Program Files', 'Google', 'Chrome', 'Application', 'chrome.exe'),
path.join(process.env['PROGRAMFILES(X86)'] || 'C:\\Program Files (x86)', 'Google', 'Chrome', 'Application', 'chrome.exe'),
] : [
'/usr/bin/google-chrome',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
];
for (const sc of systemCandidates) {
if (fs.existsSync(sc)) return sc;
}
return undefined;
}
async function run() {
const server = http.createServer((req, res) => {
const url = new URL(req.url, 'http://127.0.0.1');
if (url.pathname.startsWith('/api/email/read/')) {
const uid = url.pathname.replace('/api/email/read/', '');
if (uid === 'fail_uid' || uid === 'net_fail') {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Mailbox disconnected' }));
return;
}
if (uid === 'crash_network') {
req.destroy();
return;
}
const markSeen = url.searchParams.get('mark_seen');
if (markSeen !== 'false') {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'mark_seen must be false' }));
return;
}
let body_html = '';
if (uid === 'img_test') {
body_html = [
'<div>',
'<p>Email with diverse images</p>',
'<!-- Inline image with alt text -->',
'<img src="cid:logo123" alt="Company Brand Logo">',
'<!-- Remote image with title -->',
'<img src="https://example.com/tracker.png" title="Tracking Pixel">',
'<!-- Image without alt or title -->',
'<img src="https://example.com/no-alt.jpg">',
'<!-- Interactive control that shouldn\'t be in print -->',
'<button type="button" class="btn-action">Interactive Action</button>',
'<!-- XSS payload -->',
'<script>window.__pwned = true;</script>',
'</div>'
].join('\n');
} else if (uid === 'long_msg') {
body_html = `<div><p>${'A'.repeat(5000)}</p></div>`;
} else {
body_html = `<div><p>Standard message body for ${uid}</p></div>`;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
uid: uid,
subject: `Subject ${uid}`,
from_name: 'Sender',
from_address: 'sender@example.com',
to: 'user@example.com',
date: '2026-10-20',
date_display: 'Oct 20, 2026',
body: `Plain text for ${uid}`,
body_html: body_html,
}));
return;
}
let filePath = path.join(ROOT, url.pathname);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
const mime = ext === '.js' ? 'application/javascript' : ext === '.html' ? 'text/html' : ext === '.css' ? 'text/css' : 'text/plain';
res.writeHead(200, { 'Content-Type': mime });
res.end(fs.readFileSync(filePath));
return;
}
res.writeHead(404);
res.end('Not found');
});
await new Promise(r => server.listen(0, '127.0.0.1', r));
const port = server.address().port;
const origin = `http://127.0.0.1:${port}`;
const launchOpts = { headless: true };
const chromePath = findChromiumPath();
if (chromePath) launchOpts.executablePath = chromePath;
let browser;
try {
browser = await chromium.launch(launchOpts);
} catch (launchErr) {
throw new Error(
`Failed to launch Chromium browser: ${launchErr.message}\n` +
'Missing or unexecutable Chromium binary.\n' +
'To install Playwright Chromium, run:\n' +
' npx playwright install chromium\n' +
'Or set PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH to a valid Chromium executable.'
);
}
const context = await browser.newContext();
const page = await context.newPage();
const results = {};
try {
await page.goto(`${origin}/static/index.html`);
await page.evaluate(() => {
if (!document.getElementById('email-lib-bulk-actions')) {
const d = document.createElement('div'); d.id = 'email-lib-bulk-actions'; document.body.appendChild(d);
}
if (!document.getElementById('email-lib-selected-count')) {
const d = document.createElement('div'); d.id = 'email-lib-selected-count'; document.body.appendChild(d);
}
});
// TEST 1: Full Print Image Sanitization and Replacement
const printImageResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
stateMod.state._selectedUids = new Set(['img_test']);
stateMod.state._libEmails = [{ uid: 'img_test', subject: 'Image Test', folder: 'INBOX' }];
let iframePrintInvoked = false;
let printDocHtml = '';
let afterPrintTriggered = false;
let hasPlaceholderElement = false;
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
iframePrintInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
const body = frame.contentDocument ? frame.contentDocument.body : null;
hasPlaceholderElement = !!body?.querySelector('.email-inline-image-placeholder');
frame.contentWindow.dispatchEvent(new Event('afterprint'));
afterPrintTriggered = true;
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
iframePrintInvoked,
afterPrintTriggered,
frameRemovedAfterPrint: frameAfter === null,
hasLogoNotice: printDocHtml.includes('[Image: Company Brand Logo]'),
hasTrackerNotice: printDocHtml.includes('[Image: Tracking Pixel]'),
hasNoAltNotice: printDocHtml.includes('[Image omitted]'),
hasPlaceholderElement,
hasRemoteImageBlockedText: printDocHtml.includes('Remote image blocked'),
hasDownloadButton: printDocHtml.includes('email-inline-image-download-btn'),
hasInteractiveActionBtn: printDocHtml.includes('btn-action'),
hasXSS: printDocHtml.includes('<script>') || printDocHtml.includes('window.__pwned'),
};
}, origin);
results.printImageSanitization = printImageResult;
// TEST 2: Partial Failure - 1 failure out of 3 emails
const partialFailureResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
const messages = [
{ uid: 'msg1', subject: 'Email One', folder: 'INBOX' },
{ uid: 'fail_uid', subject: 'Failing Email', folder: 'INBOX' },
{ uid: 'msg2', subject: 'Email Two', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
let printDocHtml = '';
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
printInvoked = true;
printDocHtml = frame.contentDocument ? frame.contentDocument.documentElement.outerHTML : '';
frame.contentWindow.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
toast,
hasMsg1: printDocHtml.includes('Subject msg1'),
hasMsg2: printDocHtml.includes('Subject msg2'),
hasOmittedNotice: printDocHtml.includes('1 selected email was unavailable and omitted from this print'),
};
}, origin);
results.partialFailureTest = partialFailureResult;
// TEST 3: All emails fail
const allFailedResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
const messages = [
{ uid: 'fail_uid', subject: 'Fail 1', folder: 'INBOX' },
{ uid: 'net_fail', subject: 'Fail 2', folder: 'INBOX' },
];
stateMod.state._selectedUids = new Set(messages.map(m => m.uid));
stateMod.state._libEmails = messages;
let printInvoked = false;
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
printInvoked = true;
};
}
});
observer.observe(document.body, { childList: true });
let thrownError = null;
try {
await mod._exportSelectedEmailPdf();
} catch (err) {
thrownError = err.message || String(err);
}
observer.disconnect();
const frame = document.getElementById('email-browser-print-frame');
const toast = document.getElementById('toast')?.textContent || '';
return {
printInvoked,
frameOpened: frame !== null,
thrownError,
toast,
};
}, origin);
results.allFailedTest = allFailedResult;
// TEST 4: Idempotent Iframe Cleanup & Window Afterprint
const cleanupResult = await page.evaluate(async (base) => {
window.API_BASE = base;
const mod = await import(`${base}/static/js/emailLibrary/index.js`);
const stateMod = await import(`${base}/static/js/emailLibrary/state.js`);
stateMod.state._selectedUids = new Set(['msg1']);
stateMod.state._libEmails = [{ uid: 'msg1', subject: 'Cleanup Test', folder: 'INBOX' }];
const observer = new MutationObserver(() => {
const frame = document.getElementById('email-browser-print-frame');
if (frame && frame.contentWindow && !frame._patched) {
frame._patched = true;
frame.contentWindow.print = function() {
window.dispatchEvent(new Event('afterprint'));
};
}
});
observer.observe(document.body, { childList: true });
await mod._exportSelectedEmailPdf();
observer.disconnect();
const frameAfter = document.getElementById('email-browser-print-frame');
return {
frameRemovedViaWindowAfterprint: frameAfter === null,
};
}, origin);
results.cleanupTest = cleanupResult;
} finally {
await browser.close();
server.close();
}
// Assertions
const t1 = results.printImageSanitization;
if (!t1.iframePrintInvoked) throw new Error('Assertion failed: print not invoked');
if (!t1.frameRemovedAfterPrint) throw new Error('Assertion failed: frame not removed');
if (!t1.hasLogoNotice) throw new Error('Assertion failed: [Image: Company Brand Logo] missing');
if (!t1.hasTrackerNotice) throw new Error('Assertion failed: [Image: Tracking Pixel] missing');
if (!t1.hasNoAltNotice) throw new Error('Assertion failed: [Image omitted] missing');
if (t1.hasPlaceholderElement) throw new Error('Assertion failed: .email-inline-image-placeholder found in print doc');
if (t1.hasRemoteImageBlockedText) throw new Error('Assertion failed: Remote image blocked leaked into print doc');
if (t1.hasDownloadButton) throw new Error('Assertion failed: download button leaked into print doc');
if (t1.hasInteractiveActionBtn) throw new Error('Assertion failed: interactive button leaked into print doc');
if (t1.hasXSS) throw new Error('Assertion failed: script tag found');
const t2 = results.partialFailureTest;
if (!t2.printInvoked) throw new Error('Assertion failed: partial print was not invoked');
if (!t2.hasMsg1 || !t2.hasMsg2) throw new Error('Assertion failed: successful messages missing from print');
if (!t2.hasOmittedNotice) throw new Error('Assertion failed: omitted notice banner missing');
if (!t2.toast.includes('Prepared 2 emails for printing; 1 unavailable')) {
throw new Error('Assertion failed: toast text mismatch: ' + t2.toast);
}
const t3 = results.allFailedTest;
if (t3.printInvoked) throw new Error('Assertion failed: print should NOT be invoked when all fail');
if (t3.frameOpened) throw new Error('Assertion failed: print frame should NOT remain in DOM');
const t4 = results.cleanupTest;
if (!t4.frameRemovedViaWindowAfterprint) throw new Error('Assertion failed: top window afterprint did not clean up frame');
console.log('ALL_BROWSER_TESTS_PASSED');
}
run().catch(err => {
console.error('Browser test failed:', err);
process.exit(1);
});
+1 -1
View File
@@ -64,7 +64,7 @@ def test_imap_connect_with_host_still_connects(monkeypatch):
def login(self, user, password):
opened["login"] = (user, password)
def _fake_open(host, port, *, starttls, timeout):
def _fake_open(host, port, *, starttls, timeout, **_policy):
opened["host"] = host
return _FakeConn()
+9 -9
View File
@@ -12,7 +12,7 @@ from routes.email_helpers import _send_smtp_message
class _FakeSMTP:
calls = []
def __init__(self, host, port, timeout=None):
def __init__(self, host, port, timeout=None, **_policy):
self.host = host
self.port = port
self.timeout = timeout
@@ -54,8 +54,8 @@ def test_send_smtp_message_supports_plain_smtp(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("none"), "from@example.com", ["to@example.com"], "hello")
@@ -68,8 +68,8 @@ def test_send_smtp_message_supports_explicit_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("starttls", port=2525), "from@example.com", ["to@example.com"], "hello")
@@ -82,8 +82,8 @@ def test_send_smtp_message_defaults_587_to_starttls(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
cfg = _cfg("", port=587)
_send_smtp_message(cfg, "from@example.com", ["to@example.com"], "hello")
@@ -96,8 +96,8 @@ def test_send_smtp_message_uses_ssl_when_configured(monkeypatch):
import routes.email_helpers as helpers
_FakeSMTP.calls = []
monkeypatch.setattr(helpers.smtplib, "SMTP", _FakeSMTP)
monkeypatch.setattr(helpers.smtplib, "SMTP_SSL", _FakeSMTPSSL)
monkeypatch.setattr(helpers, "_PolicySMTP", _FakeSMTP)
monkeypatch.setattr(helpers, "_PolicySMTP_SSL", _FakeSMTPSSL)
_send_smtp_message(_cfg("ssl", port=465), "from@example.com", ["to@example.com"], "hello")
+12 -12
View File
@@ -89,8 +89,8 @@ async def test_test_connection_oauth_account_uses_xoauth2_for_imap_and_smtp():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._get_valid_google_token", return_value="ya29.live") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -141,8 +141,8 @@ async def test_test_connection_password_account_still_uses_login():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection", return_value=mock_imap_conn), \
mock.patch("routes.email_routes.smtplib.SMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes.smtplib.SMTP_SSL", return_value=mock_smtp_conn):
mock.patch("routes.email_routes._PolicySMTP", return_value=mock_smtp_conn), \
mock.patch("routes.email_routes._PolicySMTP_SSL", return_value=mock_smtp_conn):
result = await test_conn(req=_FakeReq(), owner="alice")
assert result["ok"] is True
@@ -187,8 +187,8 @@ async def test_test_connection_rejects_non_google_hosts_before_oauth_auth():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -238,8 +238,8 @@ async def test_test_connection_rejects_insecure_oauth_transports_before_auth():
with mock.patch("core.database.SessionLocal", Factory), \
mock.patch("routes.email_routes._open_imap_connection") as open_imap, \
mock.patch("routes.email_routes.smtplib.SMTP") as open_smtp, \
mock.patch("routes.email_routes.smtplib.SMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._PolicySMTP") as open_smtp, \
mock.patch("routes.email_routes._PolicySMTP_SSL") as open_smtp_ssl, \
mock.patch("routes.email_routes._get_valid_google_token") as token_getter:
result = await test_conn(req=_FakeReq(), owner="alice")
@@ -336,10 +336,10 @@ async def test_test_connection_verifies_imap_tls_before_loading_oauth_token(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_helpers.imaplib.IMAP4",
"routes.email_helpers._PolicyIMAP4",
return_value=starttls_conn,
) as imap_cls, mock.patch(
"routes.email_helpers.imaplib.IMAP4_SSL",
"routes.email_helpers._PolicyIMAP4_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as imap_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
@@ -412,10 +412,10 @@ async def test_test_connection_verifies_smtp_tls_before_loading_oauth_token(
"routes.email_routes.ssl.create_default_context",
return_value=context,
), mock.patch(
"routes.email_routes.smtplib.SMTP",
"routes.email_routes._PolicySMTP",
return_value=starttls_smtp,
) as smtp_cls, mock.patch(
"routes.email_routes.smtplib.SMTP_SSL",
"routes.email_routes._PolicySMTP_SSL",
side_effect=ssl.SSLCertVerificationError("untrusted certificate"),
) as smtp_ssl_cls, mock.patch(
"routes.email_routes._get_valid_google_token"
@@ -79,3 +79,68 @@ async def test_inherited_authority_still_cannot_widen_to_ungranted_bash(tmp_path
assert pending.process_operation is None
_, result = await _dispatch(child, "bash", "printf approved-run", exact)
assert result.get("blocked") is True
@pytest.fixture
def agent_workspace():
import os
from src.constants import AGENT_WORKSPACE_DIR
os.makedirs(AGENT_WORKSPACE_DIR, exist_ok=True)
return os.path.realpath(AGENT_WORKSPACE_DIR)
def test_approval_without_a_workspace_seals_a_launch_in_the_agent_workspace(agent_workspace):
# Agent mode with no folder picked is the default. bash already runs in the
# agent workspace there (agent_cwd), so that is where its approval seals.
authority = _authority("", "get_workspace")
pending, _ = _approve(authority, "bash", "pwd")
bound = pending.process_operation
assert bound is not None
assert bound.launch is not None
assert bound.launch.scope.root.path == agent_workspace
async def test_approved_bash_without_a_workspace_runs_in_the_agent_workspace(agent_workspace):
authority = _authority("", "get_workspace")
_, exact = _approve(authority, "bash", "pwd")
description, result = await _dispatch(authority, "bash", "pwd", exact)
assert result.get("failure_kind") != "resource_identity_denied", result
assert result.get("exit_code") == 0, result
containment = result.get("containment") or {}
assert containment.get("workspace", agent_workspace) == agent_workspace, containment
async def test_inherited_authority_without_a_workspace_still_cannot_widen(agent_workspace):
from dataclasses import replace
child = replace(_authority("", "get_workspace"), inherited=True)
pending, exact = _approve(child, "bash", "pwd")
assert pending.process_operation is None
_, result = await _dispatch(child, "bash", "pwd", exact)
assert result.get("blocked") is True
async def test_approved_write_without_a_workspace_lands_in_the_agent_workspace(agent_workspace):
# write_file had the same gap: with no workspace the approval sealed no
# root, so "approve" ended in "no sealed resource identity".
import os
target = "approved-write-6635.txt"
content = f"{target}\nhello"
authority = _authority("", "get_workspace")
pending, exact = _approve(authority, "write_file", content)
assert pending.resource_operation is not None
_, result = await _dispatch(authority, "write_file", content, exact)
assert result.get("failure_kind") != "resource_identity_denied", result
written = os.path.join(agent_workspace, target)
try:
with open(written) as fh:
assert fh.read().strip() == "hello"
finally:
if os.path.exists(written):
os.remove(written)
+12
View File
@@ -25,3 +25,15 @@ def test_image_model_picker_lives_in_the_settings_container():
def test_picker_persists_with_the_shared_localstorage_key():
assert "localStorage.setItem(_GE_MODEL_KEY" in GALLERY_JS
assert "localStorage.getItem(_GE_MODEL_KEY)" in GALLERY_JS
def test_cloud_image_models_are_listed_alongside_the_local_ones():
"""Gateway endpoints (OpenRouter) register as llm but carry image models.
The picker must surface those ids too — otherwise the only selectable
edit model is the local Qwen server.
"""
routes = (ROOT / "routes/editor_agent_routes.py").read_text(encoding="utf-8")
assert "from src.image_model_ids import looks_like_image_generation_model" in routes
assert 'image_endpoint = getattr(ep, "model_type", None) == "image"' in routes
assert "if not image_endpoint and not looks_like_image_generation_model(name):" in routes
+54 -23
View File
@@ -1,5 +1,11 @@
import pytest
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models, is_prequantized
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
# Rank authored inputs rather than publication catalog snapshots.
pytestmark = pytest.mark.usefixtures("publication_catalog")
def _8gb_vram_system():
@@ -14,38 +20,63 @@ def _8gb_vram_system():
}
def test_gemma4_12b_in_catalog():
catalog = {m["name"]: m for m in get_models()}
assert "google/gemma-4-12B-it" in catalog, "gemma-4-12B-it missing from catalog"
GEMMA = "google/gemma-4-12B-it"
def test_gemma4_12b_has_gguf_source():
catalog = {m["name"]: m for m in get_models()}
entry = catalog["google/gemma-4-12B-it"]
assert entry.get("gguf_sources"), "gemma-4-12B-it has no gguf_sources"
repos = [s["repo"] for s in entry["gguf_sources"]]
assert "unsloth/gemma-4-12B-it-GGUF" in repos
def _input_row(rows, name):
return next(r for r in rows if r["name"] == name)
def _qat_rows(rows):
return [r for r in rows if r["name"].startswith(GEMMA + "-qat-")]
def test_gemma4_12b_user_catalog_row_wins_the_merge(publication_catalog, monkeypatch):
"""A dynamic cache listing the same repo must not replace or duplicate the
user-catalog row; rows only the cache knows are still merged in."""
from services.hwfit import hf_discovery
monkeypatch.setattr(hf_discovery, "load_cached_hf_collection_models", lambda: [
{"name": GEMMA, "quantization": "F16", "gguf_sources": [{"repo": "test/cache-GGUF", "file": "cache.gguf"}]},
{"name": "test/cache-only", "quantization": "F16", "gguf_sources": []},
])
merged = get_models()
names = [m["name"] for m in merged]
assert names.count(GEMMA) == 1
assert "test/cache-only" in names
entry = next(m for m in merged if m["name"] == GEMMA)
assert entry["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
def test_gemma4_12b_ranked_row_carries_its_gguf_source(publication_catalog):
hit = next(r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20) if r["name"] == GEMMA)
assert hit["gguf_sources"] == _input_row(publication_catalog, GEMMA)["gguf_sources"]
# The F16 input does not fit 8 GB, so the fit falls back to a GGUF quant.
assert hit["quant"] == "Q4_K_M"
def test_gemma4_12b_rank_models_returns_it_for_8gb_vram():
results = rank_models(_8gb_vram_system(), search="gemma-4-12B-it", limit=20)
names = [r["name"] for r in results]
assert "google/gemma-4-12B-it" in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
assert GEMMA in names, "rank_models did not return gemma-4-12B-it for 8 GB VRAM"
def test_gemma4_12b_qat_entries_in_catalog():
def test_gemma4_12b_qat_entries_rank_with_their_native_quant(publication_catalog):
qat = _qat_rows(publication_catalog)
assert len(qat) == 2
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
for row in qat:
assert ranked[row["name"]]["quant"] == row["quantization"]
def test_gemma4_12b_qat_entries_are_prequantized(publication_catalog):
catalog = {m["name"]: m for m in get_models()}
assert "google/gemma-4-12B-it-qat-int4" in catalog
assert "google/gemma-4-12B-it-qat-int8" in catalog
for row in _qat_rows(publication_catalog):
assert is_prequantized(catalog[row["name"]])
def test_gemma4_12b_qat_entries_are_prequantized():
catalog = {m["name"]: m for m in get_models()}
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int4"])
assert is_prequantized(catalog["google/gemma-4-12B-it-qat-int8"])
def test_gemma4_12b_qat_entries_have_no_gguf():
catalog = {m["name"]: m for m in get_models()}
assert catalog["google/gemma-4-12B-it-qat-int4"]["gguf_sources"] == []
assert catalog["google/gemma-4-12B-it-qat-int8"]["gguf_sources"] == []
def test_gemma4_12b_qat_entries_have_no_gguf(publication_catalog):
ranked = {r["name"]: r for r in rank_models(_8gb_vram_system(), search="gemma-4-12B-it-qat", limit=20)}
for row in _qat_rows(publication_catalog):
assert ranked[row["name"]]["gguf_sources"] == []
+90 -9
View File
@@ -3,8 +3,33 @@
The handler did `n = int(gpu_count)` with no guard, so `?gpu_count=abc` (or any
non-integer) raised ValueError -> HTTP 500. A malformed count is now ignored,
matching how the neighbouring gpu_group param is already parsed.
The count parsing runs only after the empty-catalog early return, so these
tests rank the authored publication catalog against a fixed two-GPU system and
spy on rank_models to prove the parsing path actually ran.
"""
import functools
from copy import deepcopy
import pytest
from routes.hwfit_routes import setup_hwfit_routes
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
SYSTEM = {
"has_gpu": True,
"backend": "cuda",
"gpu_name": "Synthetic GPU",
"gpu_vram_gb": 24.0,
"gpu_count": 2,
"gpus": [
{"index": 0, "name": "Synthetic GPU", "vram_gb": 12.0},
{"index": 1, "name": "Synthetic GPU", "vram_gb": 12.0},
],
"gpu_groups": [{"name": "Synthetic GPU", "vram_each": 12.0, "count": 2, "indices": [0, 1], "vram_total": 24.0}],
"available_ram_gb": 32.0,
"total_ram_gb": 32.0,
}
def _get_models():
@@ -15,24 +40,80 @@ def _get_models():
raise AssertionError("hwfit /models route not found")
def test_non_numeric_gpu_count_does_not_raise():
@pytest.fixture
def ranked_systems(publication_catalog, monkeypatch):
"""Fix detection and record every system the handler hands to rank_models."""
from services.hwfit import fit, hardware
monkeypatch.setattr(hardware, "detect_system", lambda **_: deepcopy(SYSTEM))
calls = []
real_rank = fit.rank_models
@functools.wraps(real_rank)
def spy(system, **kwargs):
calls.append(deepcopy(system))
return real_rank(system, **kwargs)
monkeypatch.setattr(fit, "rank_models", spy)
return calls
def _assert_ranked(result, ranked_systems):
assert isinstance(result, dict)
assert "error" not in result
assert isinstance(result["models"], list) and result["models"]
# Reaching rank_models means the empty-catalog return was not taken and the
# manual-hardware and gpu_count parsing ran first.
assert len(ranked_systems) == 1
assert ranked_systems[0] == result["system"]
return result["system"]
def test_non_numeric_gpu_count_does_not_raise(ranked_systems):
handler = _get_models()
# Previously raised ValueError (HTTP 500); now degrades to a normal ranking.
result = handler(gpu_count="abc")
assert isinstance(result, dict)
system = _assert_ranked(handler(gpu_count="abc"), ranked_systems)
# Ignored like an omitted count: the auto pool keeps both GPUs and no
# explicit-count GPU-only pin is applied.
assert system["detected_gpu_count"] == 2
assert system["active_group"]["use_count"] == 2
assert system["gpu_count"] == 2
assert "gpu_only" not in system
def test_numeric_gpu_count_still_accepted():
def test_numeric_gpu_count_still_accepted(ranked_systems):
handler = _get_models()
result = handler(gpu_count="0")
assert isinstance(result, dict)
system = _assert_ranked(handler(gpu_count="0"), ranked_systems)
# 0 switches to RAM-only ranking.
assert system["detected_gpu_count"] == 2
assert system["has_gpu"] is False
assert system["gpu_count"] == 0
assert system["gpu_only"] is False
assert "active_group" not in system
def test_non_numeric_manual_gpu_count_does_not_raise():
def test_non_numeric_manual_gpu_count_does_not_raise(ranked_systems):
# manual_gpu_count is the other count param on this endpoint (the hardware
# simulator in _apply_manual_hardware). A non-numeric value must also degrade
# (default to 1) rather than 500, so the endpoint's count parsing is fully
# covered.
handler = _get_models()
result = handler(manual_mode="gpu", manual_gpu_count="abc")
assert isinstance(result, dict)
system = _assert_ranked(handler(manual_mode="gpu", manual_gpu_count="abc"), ranked_systems)
assert system["manual_hardware"] is True
assert system["gpu_name"] == "Simulated CUDA GPU"
assert system["gpu_count"] == 1
assert system["gpu_groups"][0]["count"] == 1
assert system["detected_gpu_count"] == 1
def test_empty_catalog_returns_before_count_parsing(ranked_systems, monkeypatch):
# Control: without catalog rows the handler stops before the parsing above,
# which is why the tests in this module must rank the authored catalog.
from services.hwfit import models
monkeypatch.setattr(models, "get_models", lambda: [])
result = _get_models()(gpu_count="abc", manual_mode="gpu", manual_gpu_count="abc")
assert result["models"] == []
assert "Model catalog is empty" in result["error"]
assert ranked_systems == []
assert "detected_gpu_count" not in result["system"]
+17 -10
View File
@@ -6,9 +6,15 @@ guarantee that non-macOS (Linux/Windows) detection is unchanged.
import json
import pytest
from services.hwfit import hardware
from services.hwfit.fit import rank_models
from services.hwfit.models import get_models
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
# Rank authored inputs rather than publication catalog snapshots.
pytestmark = pytest.mark.usefixtures("publication_catalog")
def _metal_system(ram_gb=16.0, vram_gb=10.7):
@@ -78,19 +84,20 @@ def test_only_gguf_or_mlx_models_recommended_on_metal():
assert unservable == [], f"{len(unservable)} non-servable models on Metal, e.g. {unservable[:3]}"
def test_qwen_catalog_entries_point_at_verified_gguf_repos():
"""Qwen GGUF-looking Cookbook rows must download GGUF repos, not the base
safetensors repositories."""
catalog = {m["name"]: m for m in get_models()}
def test_qwen_gguf_rows_rank_with_their_gguf_downloads_on_metal(publication_catalog):
"""Qwen GGUF-looking Cookbook rows must download the GGUF repo/file their
catalog row names, not the base safetensors repository."""
expected = {
"Qwen/Qwen3.5-9B": ("unsloth/Qwen3.5-9B-GGUF", "Qwen3.5-9B-Q4_K_M.gguf"),
"Qwen/Qwen3.6-27B": ("unsloth/Qwen3.6-27B-GGUF", "Qwen3.6-27B-Q4_K_M.gguf"),
"Qwen/Qwen3.6-35B-A3B": ("unsloth/Qwen3.6-35B-A3B-GGUF", "Qwen3.6-35B-A3B-UD-Q4_K_M.gguf"),
r["name"]: r["gguf_sources"]
for r in publication_catalog
if r["name"].startswith("Qwen/Qwen3.") and r["gguf_sources"]
}
assert len(expected) == 3
ranked = {r["name"]: r for r in rank_models(_metal_system(), search="Qwen/Qwen3.", limit=50)}
for model_name, (repo, filename) in expected.items():
sources = catalog[model_name].get("gguf_sources") or []
assert any(src.get("repo") == repo and src.get("file") == filename for src in sources)
for model_name, sources in expected.items():
assert ranked[model_name]["gguf_sources"] == sources
assert all(src["repo"] != model_name and src["file"].endswith(".gguf") for src in ranked[model_name]["gguf_sources"])
def test_safetensors_models_still_recommended_on_cuda():
+10 -4
View File
@@ -1,9 +1,15 @@
import pytest
from services.hwfit.fit import analyze_model, rank_models
from services.hwfit.models import (
get_models,
infer_quantization_from_name,
is_prequantized,
)
from tests.hwfit_publication_fixtures import publication_catalog # noqa: F401
# Rank authored inputs rather than publication catalog snapshots.
pytestmark = pytest.mark.usefixtures("publication_catalog")
def _dual_5060ti_system():
@@ -35,7 +41,7 @@ def test_infers_native_hf_quant_formats_from_repo_names():
def test_nvfp4_catalog_quant_is_preserved():
catalog = {m["name"]: m for m in get_models()}
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
model = catalog["test/native-NVFP4"]
assert model["quantization"] == "NVFP4"
assert is_prequantized(model)
@@ -43,7 +49,7 @@ def test_nvfp4_catalog_quant_is_preserved():
def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
catalog = {m["name"]: m for m in get_models()}
model = catalog["txn545/Qwen3.5-122B-A10B-NVFP4"]
model = catalog["test/native-NVFP4"]
fit = analyze_model(model, _dual_5060ti_system())
assert fit["quant"] == "NVFP4"
@@ -51,10 +57,10 @@ def test_nvfp4_search_result_is_not_gguf_or_cpu_offload():
results = rank_models(
_dual_5060ti_system(),
search="Qwen3.5-122B-A10B-NVFP4",
search="native-NVFP4",
limit=10,
)
hit = next(r for r in results if r["name"] == "txn545/Qwen3.5-122B-A10B-NVFP4")
hit = next(r for r in results if r["name"] == "test/native-NVFP4")
assert hit["quant"] == "NVFP4"
assert hit["run_mode"] != "cpu_offload"
+1 -1
View File
@@ -281,7 +281,7 @@ def test_open_imap_connection_shuts_down_on_starttls_failure(monkeypatch):
))
conn.starttls = MagicMock(side_effect=RuntimeError("STARTTLS rejected"))
monkeypatch.setattr(helpers.imaplib, "IMAP4", lambda *a, **kw: conn)
monkeypatch.setattr(helpers, "_PolicyIMAP4", lambda *a, **kw: conn)
raised = False
try:
+257
View File
@@ -0,0 +1,257 @@
"""Regression tests for SSRF hardening on the manage_endpoints agent tool.
Verifies that the do_manage_endpoints tool function rejects user-supplied URLs
that resolve to link-local (cloud metadata), non-HTTP schemes, or (when
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses -- applying
the shared outbound-URL policy in check_outbound_url(). The HTTP route
(POST /api/model-endpoints) receives the same guard in #6316.
The agent tool path bypasses the HTTP route entirely, so it needs its own
validation. A prompt-injection payload smuggled into a skill, note, fetched
page, or email could instruct the model to register a cloud-metadata or
internal-network endpoint via this tool.
"""
import ipaddress
import json
import os
import sys
import types
import pytest
import src.url_safety as _url_safety
_real_check_outbound_url = _url_safety.check_outbound_url
def _fake_resolver(host):
"""Resolver that returns deterministic IPs without real DNS."""
_HOST_MAP = {
"169.254.169.254": ["169.254.169.254"],
"metadata.google.internal": ["169.254.169.254"],
"localhost": ["127.0.0.1"],
"127.0.0.1": ["127.0.0.1"],
"0.0.0.0": ["0.0.0.0"],
"example.com": ["93.184.216.34"],
"192.168.1.1": ["192.168.1.1"],
"10.0.0.1": ["10.0.0.1"],
"api.openai.com": ["104.18.6.192"],
}
if host in _HOST_MAP:
return _HOST_MAP[host]
try:
ipaddress.ip_address(host)
return [host]
except ValueError:
pass
raise OSError(f"Name or service not known: {host}")
def _check_with_fake_resolver(url, *, block_private=False, resolver=None):
"""Call the real check_outbound_url with our fake resolver."""
return _real_check_outbound_url(url, block_private=block_private, resolver=_fake_resolver)
def _make_content(base_url, *, name="", api_key=""):
"""Build a JSON content string for the manage_endpoints 'add' action."""
return json.dumps({
"action": "add",
"name": name,
"base_url": base_url,
"api_key": api_key,
})
class _FakeQuery:
def __init__(self, model):
self._model = model
def filter(self, *args):
return self
def first(self):
return None
def all(self):
return []
class _FakeDB:
def __init__(self):
self.added = []
self.committed = False
def query(self, model):
return _FakeQuery(model)
def add(self, obj):
self.added.append(obj)
def commit(self):
self.committed = True
def close(self):
pass
class _FakeModelEndpoint:
def __init__(self, **kwargs):
for k, v in kwargs.items():
setattr(self, k, v)
def _setup_mocks(monkeypatch, db):
"""Set up faked core.database and patched check_outbound_url."""
fake_core_db = types.ModuleType("core.database")
fake_core_db.SessionLocal = lambda: db
fake_core_db.ModelEndpoint = _FakeModelEndpoint
monkeypatch.setitem(sys.modules, "core.database", fake_core_db)
monkeypatch.setattr(_url_safety, "check_outbound_url", _check_with_fake_resolver)
if "src.agent_tools.admin_tools" in sys.modules:
monkeypatch.delitem(sys.modules, "src.agent_tools.admin_tools", raising=False)
def _get_do_manage_endpoints():
"""Import and return do_manage_endpoints from the reloaded admin_tools."""
from src.agent_tools.admin_tools import do_manage_endpoints
return do_manage_endpoints
@pytest.mark.asyncio
async def test_cloud_metadata_blocked(monkeypatch):
"""Cloud metadata IP (169.254.169.254) must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://169.254.169.254/latest/meta-data/")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_non_http_scheme_blocked(monkeypatch):
"""Non-HTTP schemes (file://, gopher://) must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("file:///etc/passwd")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_loopback_accepted_by_default(monkeypatch):
"""Loopback addresses are accepted by default (local-first design).
The agent tool uses check_outbound_url directly, which allows
loopback by default -- this is correct for local model endpoints
(Ollama, llama.cpp, etc.). Loopback is only blocked in strict mode.
"""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://localhost:11434/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
@pytest.mark.asyncio
async def test_strict_mode_blocks_private(monkeypatch):
"""MODELENDPOINT_BLOCK_PRIVATE_IPS=true blocks private IPs."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
monkeypatch.setenv("MODELENDPOINT_BLOCK_PRIVATE_IPS", "true")
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://192.168.1.1:8080/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_safe_url_accepted(monkeypatch):
"""A safe public URL should be accepted and stored."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("https://api.openai.com/v1", name="OpenAI")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
assert db.committed is True
@pytest.mark.asyncio
async def test_empty_url_rejected(monkeypatch):
"""An empty base_url must be rejected before SSRF check."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "base_url is required" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_gopher_scheme_blocked(monkeypatch):
"""gopher:// scheme must be rejected."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("gopher://evil:6379/secret")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert len(db.added) == 0
@pytest.mark.asyncio
async def test_error_not_leaked(monkeypatch):
"""Error messages must not leak raw exception text."""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://169.254.169.254/latest/meta-data/")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 1
assert "Rejected" in result["error"]
assert "Traceback" not in result["error"]
@pytest.mark.asyncio
async def test_ipv6_loopback_accepted_by_default(monkeypatch):
"""::1 must be treated as loopback (accepted local-first), not reserved.
Proves this branch carries the dfd1f7e7 url_safety fix after rebasing
onto current dev: the pre-fix guard checked is_reserved before is_loopback
and rejected ::1 even in default local-first mode.
"""
db = _FakeDB()
_setup_mocks(monkeypatch, db)
do_manage_endpoints = _get_do_manage_endpoints()
content = _make_content("http://[::1]:11434/v1")
result = await do_manage_endpoints(content, owner="admin")
assert result["exit_code"] == 0
assert "Added endpoint" in result["response"]
assert len(db.added) == 1
@@ -0,0 +1,289 @@
"""manage_tasks must apply the admin-only task-action policy.
``POST /api/tasks`` (routes/task/task_routes.py) and the scheduler
(src/task_scheduler.py) both refuse ``action`` tasks whose action is in
``ADMIN_ONLY_TASK_ACTIONS`` (run_local, run_script, ssh_command,
cookbook_serve) for owners without admin task privileges — those actions run
``subprocess.run(shell=True)`` or SSH with no sandbox. The model-facing
``do_manage_tasks`` applied neither check, so an agent could store such a task
on create/edit and resume/run it; the scheduler would only pause it at
execution time. Regression for issue #6021: the tool now enforces the same
policy on create, edit, resume and run, and keeps allowing admins and the
non-privileged actions.
"""
import json
import tempfile
import pytest
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy.pool import NullPool
from tests.helpers.import_state import clear_fake_database_modules
clear_fake_database_modules()
import core.database as cdb
from core.database import ScheduledTask
import src.task_action_policy as policy_mod
import src.tools.system as system_mod
from src.tools.system import do_manage_tasks
_TMPDB = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
_ENGINE = create_engine(
f"sqlite:///{_TMPDB.name}",
connect_args={"check_same_thread": False},
poolclass=NullPool,
)
cdb.Base.metadata.create_all(_ENGINE)
_TS = sessionmaker(bind=_ENGINE, autoflush=False, autocommit=False)
@pytest.fixture(autouse=True)
def _bind_session_factory(monkeypatch):
# do_manage_tasks does `from core.database import SessionLocal` at call
# time. Bind it per test (not at import) so this file stays
# order-independent from sibling files that point it at their own DB.
monkeypatch.setattr(cdb, "SessionLocal", _TS)
@pytest.fixture
def privileges(monkeypatch):
"""Drive owner_has_admin_task_privileges from a plain dict."""
admins = {"root"}
monkeypatch.setattr(
policy_mod, "owner_has_admin_task_privileges",
lambda owner: owner in admins,
)
return admins
def _seed(task_id, owner, task_type="llm", action=None, status="active"):
db = _TS()
try:
db.add(ScheduledTask(
id=task_id, owner=owner, name=task_id, prompt="original",
task_type=task_type, action=action, trigger_type="webhook",
status=status, output_target="session",
))
db.commit()
finally:
db.close()
def _get(task_id):
db = _TS()
try:
return db.query(ScheduledTask).filter(ScheduledTask.id == task_id).first()
finally:
db.close()
def _count(owner):
db = _TS()
try:
return db.query(ScheduledTask).filter(ScheduledTask.owner == owner).count()
finally:
db.close()
@pytest.mark.asyncio
@pytest.mark.parametrize("action_name", sorted(policy_mod.ADMIN_ONLY_TASK_ACTIONS))
async def test_create_admin_only_action_denied_for_non_admin(privileges, action_name):
before = _count("alice")
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": action_name,
"name": "pwn", "schedule": "daily", "scheduled_time": "09:00",
}), owner="alice")
assert out["exit_code"] == 1, out
assert "requires admin privileges" in out["error"]
assert _count("alice") == before
@pytest.mark.asyncio
async def test_create_admin_only_action_allowed_for_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": "run_local",
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
}), owner="root")
assert out["exit_code"] == 0, out
assert _get(out["task_id"]).action == "run_local"
@pytest.mark.asyncio
async def test_create_regular_action_allowed_for_non_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": "tidy_sessions",
"name": "ok", "schedule": "daily", "scheduled_time": "09:00",
}), owner="alice")
assert out["exit_code"] == 0, out
@pytest.mark.asyncio
async def test_create_llm_task_allowed_for_non_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "llm", "prompt": "summarize",
"schedule": "daily", "scheduled_time": "09:00",
}), owner="alice")
assert out["exit_code"] == 0, out
@pytest.mark.asyncio
async def test_edit_cannot_turn_llm_task_into_shell_action(privileges):
_seed("alice-llm", "alice")
out = await do_manage_tasks(json.dumps({
"action": "edit", "task_id": "alice-llm",
"task_type": "action", "action_name": "ssh_command",
}), owner="alice")
assert out["exit_code"] == 1, out
assert "requires admin privileges" in out["error"]
task = _get("alice-llm")
assert task.task_type == "llm" and task.action is None
@pytest.mark.asyncio
async def test_edit_cannot_swap_action_name_to_admin_only(privileges):
_seed("alice-action", "alice", task_type="action", action="tidy_sessions")
out = await do_manage_tasks(json.dumps({
"action": "edit", "task_id": "alice-action", "action_name": "run_script",
}), owner="alice")
assert out["exit_code"] == 1, out
assert _get("alice-action").action == "tidy_sessions"
@pytest.mark.asyncio
async def test_edit_of_unrelated_field_on_admin_only_task_denied_for_non_admin(privileges):
# A task that slipped in before the policy (or via demotion of its owner)
# cannot be kept alive by a non-admin: the route rejects the same edit.
_seed("alice-legacy", "alice", task_type="action", action="run_local")
out = await do_manage_tasks(json.dumps({
"action": "edit", "task_id": "alice-legacy", "name": "renamed",
}), owner="alice")
assert out["exit_code"] == 1, out
assert _get("alice-legacy").name == "alice-legacy"
@pytest.mark.asyncio
async def test_resume_admin_only_task_denied_for_non_admin(privileges):
_seed("alice-paused", "alice", task_type="action", action="run_local", status="paused")
out = await do_manage_tasks(json.dumps({
"action": "resume", "task_id": "alice-paused",
}), owner="alice")
assert out["exit_code"] == 1, out
assert _get("alice-paused").status == "paused"
@pytest.mark.asyncio
async def test_pause_admin_only_task_still_allowed(privileges):
# Narrowing is always fine: a non-admin may pause a task they own.
_seed("alice-active", "alice", task_type="action", action="run_local")
out = await do_manage_tasks(json.dumps({
"action": "pause", "task_id": "alice-active",
}), owner="alice")
assert out["exit_code"] == 0, out
assert _get("alice-active").status == "paused"
@pytest.mark.asyncio
async def test_run_admin_only_task_denied_before_scheduler(privileges, monkeypatch):
_seed("alice-run", "alice", task_type="action", action="ssh_command")
calls = []
class _Sched:
async def run_task_now(self, task_id):
calls.append(task_id)
return True
import src.event_bus as event_bus
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
out = await do_manage_tasks(json.dumps({
"action": "run", "task_id": "alice-run",
}), owner="alice")
assert out["exit_code"] == 1, out
assert calls == []
@pytest.mark.asyncio
async def test_run_admin_only_task_allowed_for_admin(privileges, monkeypatch):
_seed("root-run", "root", task_type="action", action="ssh_command")
calls = []
class _Sched:
async def run_task_now(self, task_id):
calls.append(task_id)
return True
import src.event_bus as event_bus
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
out = await do_manage_tasks(json.dumps({
"action": "run", "task_id": "root-run",
}), owner="root")
assert out["exit_code"] == 0, out
assert calls == ["root-run"]
def test_policy_helper_uses_shared_constants(privileges):
# The tool must consult the shared policy module, not a private copy of
# the action names, so a new admin-only action is covered everywhere.
assert system_mod._admin_task_action_denied("alice", "action", "run_local") is not None
assert system_mod._admin_task_action_denied("root", "action", "run_local") is None
assert system_mod._admin_task_action_denied("alice", "action", "tidy_sessions") is None
assert system_mod._admin_task_action_denied("alice", "llm", "run_local") is None
@pytest.mark.asyncio
async def test_run_admin_only_task_by_name_denied_for_non_admin(privileges, monkeypatch):
_seed("alice-run-name", "alice", task_type="action", action="ssh_command")
calls = []
class _Sched:
async def run_task_now(self, task_id):
calls.append(task_id)
return True
import src.event_bus as event_bus
monkeypatch.setattr(event_bus, "get_task_scheduler", lambda: _Sched())
out = await do_manage_tasks(json.dumps({
"action": "run", "name": "alice-run-name",
}), owner="alice")
assert out["exit_code"] == 1, out
assert "requires admin privileges" in out["error"]
assert calls == []
@pytest.mark.asyncio
async def test_create_admin_only_action_seals_authority_for_admin(privileges):
out = await do_manage_tasks(json.dumps({
"action": "create", "task_type": "action", "action_name": "run_local",
"name": "sealed-task", "schedule": "daily", "scheduled_time": "09:00",
}), owner="root")
assert out["exit_code"] == 0, out
task = _get(out["task_id"])
assert task.action == "run_local"
assert task.request_authority_json is not None
def test_real_owner_has_admin_task_privileges_branches(monkeypatch):
"""Verify owner_has_admin_task_privileges without mocking."""
# Auth disabled branch
monkeypatch.setenv("AUTH_ENABLED", "false")
assert policy_mod.owner_has_admin_task_privileges("alice") is True
assert policy_mod.owner_has_admin_task_privileges(None) is True
# Auth enabled branch with configured AuthManager
monkeypatch.delenv("AUTH_ENABLED", raising=False)
class FakeAuth:
is_configured = True
def is_admin(self, u):
return u == "admin"
import core.auth as c_auth
monkeypatch.setattr(c_auth, "AuthManager", lambda: FakeAuth())
assert policy_mod.owner_has_admin_task_privileges("admin") is True
assert policy_mod.owner_has_admin_task_privileges("alice") is False
assert policy_mod.owner_has_admin_task_privileges("internal-tool") is True
assert policy_mod.owner_has_admin_task_privileges(None) is False
+228
View File
@@ -264,3 +264,231 @@ def test_launchers_export_the_port_they_serve_on():
text = (_repo_root() / name).read_text(encoding="utf-8")
assert uvicorn_flag in text, f"{name}: launcher no longer passes {uvicorn_flag}"
assert export in text, f"{name}: serves on a port the app cannot read back"
# ── Callback & Exchange Expired State Handling ───────────────────
def test_oauth_callback_expired_state_returns_dedicated_expired_page(monkeypatch):
"""An unknown or expired generic MCP OAuth state in the callback must return
the dedicated 'Authorization Expired' page instead of a bare 403 'Admin only'
or a 404 'Server not found'."""
from unittest.mock import MagicMock
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
# Simulate unauthenticated request that would fail admin check if invoked
def _deny_admin(request):
raise HTTPException(403, "Admin only")
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
# Empty DB / no matching server
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return None
class FakeSession:
def query(self, *a):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
# Both code and state are provided, but state is not in _pending (expired/unknown)
resp = client.get("/api/mcp/oauth/callback?code=some-code&state=expired-state-123")
assert resp.status_code == 400
assert "Authorization Expired" in resp.text
assert (
"This authorization round has expired. Press Reconnect in Odysseus and try again."
in resp.text
)
# Ensure it did not return the bare 403 "Admin only"
assert "Admin only" not in resp.text
def test_oauth_callback_active_pending_state_succeeds_without_admin(monkeypatch):
"""An active pending generic MCP OAuth state resolves without requiring admin auth."""
from unittest.mock import MagicMock
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
# Admin check fails if invoked
def _deny_admin(request):
raise HTTPException(403, "Admin only")
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
async def go():
fut = mcp_oauth.register_pending("pending-round-1")
resp = client.get("/api/mcp/oauth/callback?code=auth-code-xyz&state=pending-round-1")
assert resp.status_code == 200
assert "Authorization Successful" in resp.text
code, state = await asyncio.wait_for(fut, timeout=1)
assert code == "auth-code-xyz"
assert state == "pending-round-1"
asyncio.run(go())
def test_oauth_callback_legacy_google_server_requires_admin(monkeypatch):
"""Legacy Google OAuth callbacks (where state is server_id of a server with
oauth_config) still enforce require_admin."""
import json
from unittest.mock import MagicMock
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
from core.database import McpServer
# Server exists in DB with Google oauth_config
google_srv = McpServer(
id="google-srv-1",
name="Google Workspace",
oauth_config=json.dumps({"provider": "google", "keys_file": "k.json", "token_file": "t.json"}),
)
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return google_srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
# When require_admin denies, 403 is raised for the legacy Google path
def _deny_admin(request):
raise HTTPException(403, "Admin only")
monkeypatch.setattr(mcp_routes, "require_admin", _deny_admin)
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/mcp/oauth/callback?code=code-123&state=google-srv-1")
assert resp.status_code == 403
assert "Admin only" in resp.text
def test_oauth_exchange_expired_generic_state_returns_expired_page(monkeypatch):
"""Remote paste-back flow for a generic MCP server returns the dedicated expired
page when the state is no longer pending, instead of 'No OAuth config.'."""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
from core.database import McpServer
monkeypatch.setattr(mcp_routes, "require_admin", lambda r: None)
# Server exists in DB without oauth_config (generic MCP server)
generic_srv = McpServer(
id="http-srv-1",
name="Remote HTTP MCP",
oauth_config=None,
)
class FakeQuery:
def filter(self, *a):
return self
def first(self):
return generic_srv
class FakeSession:
def query(self, *a):
return FakeQuery()
def close(self):
pass
monkeypatch.setattr(mcp_routes, "SessionLocal", lambda: FakeSession())
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.post(
"/api/mcp/oauth/exchange/http-srv-1",
data={"callback_url": "http://localhost:7000/api/mcp/oauth/callback?code=code-1&state=expired-state"},
)
assert resp.status_code == 400
assert "Authorization Expired" in resp.text
assert (
"This authorization round has expired. Press Reconnect in Odysseus and try again."
in resp.text
)
assert "No OAuth config." not in resp.text
def test_oauth_callback_missing_state_returns_malformed_error_not_expired():
"""Missing state parameter in callback must return a bad-request error and must
not be treated as an expired authorization round."""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/mcp/oauth/callback?code=some-code")
assert resp.status_code == 400
assert "No OAuth state parameter found in the callback URL." in resp.text
assert "Authorization Expired" not in resp.text
assert "This authorization round has expired" not in resp.text
def test_oauth_callback_missing_code_returns_malformed_error_not_expired():
"""Missing code parameter in callback must return a bad-request error and must
not be treated as an expired authorization round."""
from unittest.mock import MagicMock
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routes.mcp import mcp_routes
app = FastAPI()
manager = MagicMock()
router = mcp_routes.setup_mcp_routes(manager)
app.include_router(router)
client = TestClient(app)
resp = client.get("/api/mcp/oauth/callback?state=some-state")
assert resp.status_code == 400
assert "No authorization code found in the URL." in resp.text
assert "Authorization Expired" not in resp.text
assert "This authorization round has expired" not in resp.text
+296
View File
@@ -0,0 +1,296 @@
"""Regression tests for SSRF hardening on model endpoint routes.
Verifies that POST /model-endpoints/test, POST /model-endpoints, and
PATCH /model-endpoints/{ep_id} reject user-supplied URLs that resolve to
link-local (cloud metadata), non-HTTP schemes, or (when
MODELENDPOINT_BLOCK_PRIVATE_IPS=true) private/loopback addresses — matching
the existing SSRF guard pattern in embedding_routes.py.
A stub resolver is injected so the loopback tests never touch real DNS.
"""
import os
import sys
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ── Resolver stub (same pattern as tests/test_url_safety.py) ────────────────
def _resolver(mapping):
"""Create a stub resolver for deterministic DNS in tests."""
def resolve(host):
if host in mapping:
return mapping[host]
raise OSError(f"unresolvable: {host}")
return resolve
LOCALHOST_V4 = _resolver({"localhost": ["127.0.0.1"]})
LOCALHOST_V6 = _resolver({"localhost": ["::1"]})
@pytest.fixture
def client(monkeypatch):
"""Build a TestClient with auth disabled and minimal DB stubs."""
monkeypatch.setenv("AUTH_ENABLED", "false")
monkeypatch.setenv("DATABASE_URL", "sqlite:///:memory:")
# Stub heavy deps that model_routes imports at module level.
# Use monkeypatch so entries are removed after the test.
for mod in [
"src.tls_overrides",
"src.llm_core",
"src.settings",
"src.constants",
"core.log_safety",
]:
if mod not in sys.modules:
monkeypatch.setitem(sys.modules, mod, MagicMock())
# Provide real endpoint_resolver so the route logic works
if "src.endpoint_resolver" not in sys.modules or isinstance(
sys.modules["src.endpoint_resolver"], MagicMock
):
er = MagicMock()
er.normalize_base = lambda url: url
er.resolve_url = lambda url: url
er.build_chat_url = lambda base, **kw: base
er.build_models_url = lambda base, **kw: base + "/v1/models"
er.build_headers = lambda base, **kw: {}
monkeypatch.setitem(sys.modules, "src.endpoint_resolver", er)
# Clear cached model_routes module so it picks up our stubs
monkeypatch.delitem(sys.modules, "routes.model_routes", raising=False)
from fastapi.testclient import TestClient
import routes.model_routes as mr
from fastapi import FastAPI
app = FastAPI()
router = mr.setup_model_routes(model_discovery=None)
app.include_router(router)
app.state.auth_enabled = False
return TestClient(app, raise_server_exceptions=False)
class TestModelEndpointSSRF:
"""SSRF protection for POST /model-endpoints/test."""
def test_cloud_metadata_blocked(self, client):
"""Cloud metadata IP (169.254.169.254) must be rejected."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_non_http_scheme_blocked(self, client):
"""Non-HTTP schemes (file://, ftp://) must be rejected."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "file:///etc/passwd"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_loopback_accepted_by_default(self, client):
"""Local-first: loopback is accepted when block_private=False."""
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
# Guard passes; probe fails (no server) but that is not SSRF.
assert "Rejected endpoint URL" not in str(resp.json())
def test_loopback_rejected_in_strict_mode(self, client):
"""Strict mode: loopback is rejected when block_private=True."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
def test_strict_mode_blocks_direct_loopback_ip(self, client):
"""Strict mode rejects 127.0.0.1 passed directly (no DNS needed)."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://127.0.0.1:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
def test_ipv6_loopback_literal_accepted_by_default(self, client):
"""::1 is loopback, not a disallowed 'reserved' address (dfd1f7e7)."""
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://[::1]:11434/v1"},
)
assert "Rejected endpoint URL" not in str(resp.json())
def test_ipv6_loopback_localhost_accepted_by_default(self, client):
"""Dual-stack hosts whose resolver answers ::1 first must work."""
with patch("src.url_safety._default_resolver", LOCALHOST_V6):
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://localhost:11434/v1"},
)
assert "Rejected endpoint URL" not in str(resp.json())
def test_ipv6_loopback_rejected_in_strict_mode(self, client):
"""Strict mode still rejects ::1."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
resp = client.post(
"/api/model-endpoints/test",
data={"base_url": "http://[::1]:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
class TestModelEndpointCreateSSRF:
"""SSRF protection for POST /model-endpoints."""
def test_cloud_metadata_blocked(self, client):
"""Cloud metadata IP must be rejected on endpoint creation."""
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://169.254.169.254/latest/meta-data/"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_non_http_scheme_blocked(self, client):
"""Non-HTTP schemes must be rejected on endpoint creation."""
resp = client.post(
"/api/model-endpoints",
data={"base_url": "gopher://evil:6379/secret"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
def test_loopback_accepted_by_default(self, client):
"""Local-first: loopback is accepted when block_private=False."""
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://localhost:11434/v1"},
)
# Guard passes; route may 500 on DB/probe but never 400 SSRF.
assert resp.status_code != 400
body = resp.text if resp.status_code == 500 else str(resp.json())
assert "Rejected endpoint URL" not in body
def test_loopback_rejected_in_strict_mode(self, client):
"""Strict mode: loopback is rejected when block_private=True."""
os.environ["MODELENDPOINT_BLOCK_PRIVATE_IPS"] = "true"
try:
with patch("src.url_safety._default_resolver", LOCALHOST_V4):
resp = client.post(
"/api/model-endpoints",
data={"base_url": "http://localhost:11434/v1"},
)
assert resp.status_code == 400
assert "Rejected" in resp.json()["detail"]
finally:
os.environ.pop("MODELENDPOINT_BLOCK_PRIVATE_IPS", None)
class TestModelEndpointPatchSSRF:
"""SSRF protection for PATCH /api/model-endpoints/{ep_id} (base_url).
The update path rewrites the stored base_url, which later fetches probe,
list models and serve chat through — it must pass the same guard as the
POST routes instead of bypassing it.
"""
def _install_fake_endpoint(self, monkeypatch, base_url="http://localhost:9999/v1"):
import routes.model_routes as mr
ep = SimpleNamespace(
id="ep1",
name="EP",
base_url=base_url,
api_key=None,
is_enabled=True,
owner=None,
hidden_models=None,
cached_models=None,
pinned_models=None,
model_type="llm",
supports_tools=None,
endpoint_kind="auto",
model_refresh_mode="auto",
model_refresh_interval=None,
model_refresh_timeout=None,
created_at=None,
updated_at=None,
)
class _FakeDb:
def query(self, _model):
return self
def filter(self, *_args, **_kwargs):
return self
def first(self):
return ep
def commit(self):
pass
def close(self):
pass
monkeypatch.setattr(mr, "SessionLocal", lambda: _FakeDb())
monkeypatch.setattr(mr, "require_admin", lambda request: None)
monkeypatch.setattr(mr, "_chatgpt_endpoint_visible", lambda ep_, request: True)
return ep
def test_patch_cloud_metadata_blocked(self, client, monkeypatch):
"""PATCH must reject the cloud metadata URL, not store it."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "http://169.254.169.254/latest/meta-data"},
)
assert resp.status_code == 400
assert "Rejected endpoint URL" in resp.json()["detail"]
assert ep.base_url == "http://localhost:9999/v1"
def test_patch_non_http_scheme_blocked(self, client, monkeypatch):
"""PATCH must reject non-HTTP schemes."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "file:///etc/passwd"},
)
assert resp.status_code == 400
assert "Rejected endpoint URL" in resp.json()["detail"]
assert ep.base_url == "http://localhost:9999/v1"
def test_patch_valid_url_accepted(self, client, monkeypatch):
"""Control: a legitimate local endpoint URL still updates fine."""
ep = self._install_fake_endpoint(monkeypatch)
resp = client.patch(
"/api/model-endpoints/ep1",
json={"base_url": "http://127.0.0.1:11434/v1"},
)
assert resp.status_code == 200
assert ep.base_url == "http://127.0.0.1:11434/v1"
+3
View File
@@ -1281,6 +1281,7 @@ def _patch_create_deps(monkeypatch, db, settings=None):
monkeypatch.setattr(model_routes, "_save_settings", lambda s: settings.update(s))
monkeypatch.setattr(endpoint_resolver, "resolve_url", lambda u: u)
monkeypatch.setattr(auth_helpers, "get_current_user", lambda req: None)
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
return settings
@@ -2000,6 +2001,7 @@ def test_explicit_proxy_test_fetches_models_with_long_timeout(monkeypatch):
monkeypatch.setattr(model_routes, "require_admin", lambda request: None)
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
calls = []
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
@@ -2038,6 +2040,7 @@ def test_explicit_proxy_add_fetches_and_caches_models_with_long_timeout(monkeypa
monkeypatch.setattr(model_routes, "_save_settings", lambda settings: None)
monkeypatch.setattr("src.auth_helpers.get_current_user", lambda request: None)
monkeypatch.setattr(model_routes, "_ping_endpoint", lambda *a, **k: (_ for _ in ()).throw(AssertionError("ping should not run when model listing succeeds")))
monkeypatch.setattr(model_routes, "check_outbound_url", lambda *a, **k: (True, "ok"))
calls = []
returned = ["NVIDIA NIM/openai/gpt-oss-120b", "mistral/mistral-small-2603"]
+2 -1
View File
@@ -117,7 +117,8 @@ async def test_remote_approval_is_bound_to_operation_and_request(manager, change
content = '{"record":"two"}'
else:
authority = replace(authority, **{"request": {"request_id": "other"}, "owner": {"owner": "bob"},
"session": {"session_id": "other"}}[change])
"session": {"session_id": "other"}}[change],
resource_roots=None) # re-seal the root for the changed owner
_, result = await dispatch(authority, tool, content, exact_approval=exact,
security_context=ToolRunSecurityContext(external_untrusted_context_seen=True))
assert result["exit_code"] == 1
@@ -0,0 +1,102 @@
"""Guarded resolution of scheduled-research settings (#5049).
The scheduler reads ``research_max_tokens`` /
``research_extraction_timeout_seconds`` / ``research_extraction_concurrency``
from settings.json with bare ``int()`` casts. A hand-edited value like
``{"research_max_tokens": "invalid"}`` used to raise inside
``TaskScheduler._execute_research_task`` and kill every scheduled research
run until settings.json was fixed by hand.
``_resolve_research_settings`` must fall back to the documented defaults
(8192 / 90 / 3) for non-numeric input while preserving valid-value behavior
(numeric strings still parse). ``src/research_handler.py`` already guards the
same reads defensively; this is the scheduler's equivalent.
"""
from __future__ import annotations
import json
from src.task_scheduler import _resolve_research_settings
def _fake_get_setting(values):
"""A get_setting stand-in backed by a plain dict."""
def get(key, default=None):
return values.get(key, default)
return get
def test_non_numeric_values_fall_back_to_documented_defaults():
get = _fake_get_setting(
{
"research_max_tokens": "invalid",
"research_extraction_timeout_seconds": "bogus",
"research_extraction_concurrency": "not-a-number",
}
)
assert _resolve_research_settings(get) == (8192, 90, 3)
def test_none_values_fall_back_to_documented_defaults():
get = _fake_get_setting(
{
"research_max_tokens": None,
"research_extraction_timeout_seconds": None,
"research_extraction_concurrency": None,
}
)
assert _resolve_research_settings(get) == (8192, 90, 3)
def test_numeric_strings_still_parse():
get = _fake_get_setting(
{
"research_max_tokens": "4096",
"research_extraction_timeout_seconds": "45",
"research_extraction_concurrency": "2",
}
)
assert _resolve_research_settings(get) == (4096, 45, 2)
def test_missing_keys_keep_documented_defaults():
assert _resolve_research_settings(_fake_get_setting({})) == (8192, 90, 3)
def test_valid_ints_pass_through():
get = _fake_get_setting(
{
"research_max_tokens": 2048,
"research_extraction_timeout_seconds": 120,
"research_extraction_concurrency": 6,
}
)
assert _resolve_research_settings(get) == (2048, 120, 6)
def test_non_finite_json_numbers_fall_back_individually():
"""JSON exponent overflow produces infinity, which int() cannot convert."""
keys = (
"research_max_tokens",
"research_extraction_timeout_seconds",
"research_extraction_concurrency",
)
valid = (4096, 45, 2)
defaults = (8192, 90, 3)
for non_finite in (json.loads("1e309"), json.loads("-1e309")):
for index, key in enumerate(keys):
values = dict(zip(keys, valid))
values[key] = non_finite
expected = list(valid)
expected[index] = defaults[index]
assert _resolve_research_settings(
_fake_get_setting(values)
) == tuple(expected)
+106
View File
@@ -0,0 +1,106 @@
"""A network failure must not be mistaken for rejection of an incident chain."""
import asyncio
import importlib.util
import io
import json
from pathlib import Path
import sys
from types import ModuleType, SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
ROOT = Path(__file__).resolve().parents[1]
INCIDENT = json.loads((ROOT / "tests/fixtures/ca-trust/incident.json").read_text())
@pytest.fixture
def validator():
checker = ModuleType("check_ca_trust")
checker.check_bundle = lambda path: None
spec = importlib.util.spec_from_file_location(
"searxng_validation", ROOT / "docker/searxng/validate_image.py"
)
module = importlib.util.module_from_spec(spec)
with patch.dict(sys.modules, {"check_ca_trust": checker}):
spec.loader.exec_module(module)
module.__file__ = str(ROOT / "tests/fixtures/ca-trust/validate_image.py")
return module
class RequestFailure(Exception):
def __init__(self, code):
super().__init__(f"curl error {code}")
self.code = code
@pytest.mark.parametrize("code", [60, 6, 28, 35, None])
def test_live_probe_requires_certificate_errors_for_every_host(validator, code):
hosts = []
async def get(url, **kwargs):
if url == "https://pypi.org/simple/certifi/":
return SimpleNamespace(status_code=200)
hosts.append(url.removeprefix("https://").removesuffix("/"))
assert kwargs["allow_redirects"] is False
if code is not None:
raise RequestFailure(code)
return SimpleNamespace(status_code=200)
client = SimpleNamespace(get=get, aclose=AsyncMock())
factory = ModuleType("searx.network.client")
def new_client(**kwargs):
assert kwargs["verify"] is True
return client
factory.new_client = new_client
errors = ModuleType("curl_cffi.requests.exceptions")
errors.RequestException = RequestFailure
modules = {"searx.network.client": factory, "curl_cffi.requests.exceptions": errors}
with patch.dict(sys.modules, modules), patch.object(sys, "path", list(sys.path)):
if code == 60:
asyncio.run(validator.check_https())
assert set(hosts) == set(INCIDENT["chains"])
else:
expected = "unexpectedly accepted" if code is None else "inconclusive"
with pytest.raises(RuntimeError, match=expected):
asyncio.run(validator.check_https())
client.aclose.assert_awaited_once()
def test_offline_probe_rejects_openssl_execution_failure(validator):
result = SimpleNamespace(returncode=1, stderr="openssl: unknown option", stdout="")
with patch.dict(validator.os.environ, {"SSL_CERT_FILE": "/unused.pem", "SSL_CERT_DIR": "/unused"}), \
patch.object(validator.subprocess, "run", return_value=result):
with pytest.raises(RuntimeError, match="not a certificate trust rejection"):
validator.check_chains()
@pytest.mark.parametrize("results", [[{"title": "ordinary result"}], []])
def test_running_probe_checks_existing_service_without_repair_or_second_boot(validator, tmp_path, results):
certificate = tmp_path / "01234567.0"
certificate.write_text("certificate double")
(tmp_path / "unrelated").write_text("ignored")
health = io.BytesIO(b"OK")
health.status = 200
search = io.BytesIO(json.dumps({"results": results}).encode())
with patch.dict(validator.os.environ, {"SSL_CERT_DIR": str(tmp_path), "GRANIAN_PORT": "8080"}), \
patch.object(validator.urllib.request, "build_opener") as opener, \
patch.object(validator, "check_chains") as chains, \
patch.object(validator, "check_bundle") as bundle, \
patch.object(validator, "check_https", new_callable=AsyncMock) as https, \
patch.object(validator.subprocess, "Popen") as boot:
opener.return_value.open.side_effect = [health, search]
if results:
validator.check_running(live=True)
else:
with pytest.raises(RuntimeError, match="no results"):
validator.check_running(live=True)
assert opener.return_value.open.call_args_list[0].args == ("http://127.0.0.1:8080/healthz",)
chains.assert_called_once_with()
bundle.assert_called_once_with(certificate)
https.assert_awaited_once()
boot.assert_not_called()
+15 -10
View File
@@ -10,17 +10,22 @@ startup. This guards that the pin stays in place.
import re
from pathlib import Path
COMPOSE = Path(__file__).resolve().parent.parent / "docker-compose.yml"
import yaml
ROOT = Path(__file__).resolve().parent.parent
COMPOSE = ROOT / "docker-compose.yml"
def test_searxng_image_is_pinned_not_latest():
text = COMPOSE.read_text(encoding="utf-8")
m = re.search(r"image:\s*\S*searxng/searxng:(\S+)", text)
assert m, "searxng image line not found in docker-compose.yml"
tag = m.group(1)
assert tag != "latest", (
"SearXNG must be pinned, not ':latest' — odysseus startup depends on its "
"healthcheck, so a broken upstream latest tag blocks the app (issue #1414)"
)
# A real version tag (date-based, e.g. 2026.5.31-7159b8aed), not a moving ref.
assert re.match(r"\d{4}\.\d", tag), f"expected a versioned tag, got {tag!r}"
image = yaml.safe_load(text)["services"]["searxng"]["image"]
assert re.fullmatch(
r"docker\.io/searxng/searxng:\d{4}\.\d[^\s@]*@sha256:[0-9a-f]{64}", image
), f"SearXNG must use a versioned immutable upstream image: {image!r}"
def test_all_standalone_compose_files_select_the_same_searxng_image():
expected = yaml.safe_load(COMPOSE.read_text())["services"]["searxng"]["image"]
for name in ("docker-compose.gpu-amd.yml", "docker-compose.gpu-nvidia.yml"):
actual = yaml.safe_load((ROOT / name).read_text())["services"]["searxng"]["image"]
assert actual == expected, f"{name} selects a different SearXNG image"
+73
View File
@@ -0,0 +1,73 @@
"""Native checks must validate the selected runtime without registry credentials."""
import json
import os
from pathlib import Path
import subprocess
import pytest
import yaml
ROOT = Path(__file__).resolve().parents[1]
def test_ci_checks_the_pinned_upstream_image_on_both_native_architectures():
workflow = yaml.safe_load((ROOT / ".github/workflows/searxng-image.yml").read_text())
events = workflow.get("on", workflow.get(True))
assert "pull_request_target" not in events
assert workflow["permissions"] == {"contents": "read"}
assert set(workflow["jobs"]) == {"validate"}
job = workflow["jobs"]["validate"]
matrix = job["strategy"]["matrix"]["include"]
assert {(row["arch"], row["runner"]) for row in matrix} == {
("amd64", "ubuntu-latest"), ("arm64", "ubuntu-24.04-arm")
}
steps = job["steps"]
assert not any("login-action" in step.get("uses", "") for step in steps)
assert all(step.get("with", {}).get("persist-credentials") is False
for step in steps if "actions/checkout@" in step.get("uses", ""))
offline = next(step for step in steps if "docker/build-push-action@" in step.get("uses", ""))
assert offline["with"]["network"] == "none"
assert offline["with"]["build-args"] == "SEARXNG_IMAGE=${{ steps.image.outputs.image }}"
live = next(step for step in steps if "validate-searxng-image.sh" in step.get("run", ""))
assert live["env"]["SEARXNG_TEST_IMAGE"] == "${{ steps.image.outputs.image }}"
@pytest.mark.parametrize("failure,expected", [("none", 0), ("exec", 7), ("up", 1), ("existing-volume", 1)])
def test_compose_probe_owns_only_its_temporary_resources(tmp_path, failure, expected):
"""Exercise the real helper with a Docker double, including failure cleanup."""
binary = tmp_path / "docker"
calls = tmp_path / "calls.jsonl"
binary.write_text('''#!/usr/bin/env python3
import json, os, sys
args = sys.argv[1:]
with open(os.environ["DOCKER_CALLS"], "a") as log:
log.write(json.dumps({"args": args, "volume": os.environ["SEARXNG_VALIDATION_VOLUME"], "secret": os.environ["SEARXNG_SECRET"]}) + "\\n")
failure = os.environ["DOCKER_FAILURE"]
if args[:2] == ["volume", "inspect"]:
sys.exit(0 if failure == "existing-volume" else 1)
if "exec" in args and failure == "exec":
sys.exit(7)
if "up" in args and failure == "up":
sys.exit(9)
''')
binary.chmod(0o755)
result = subprocess.run(["bash", str(ROOT / ".github/scripts/validate-searxng-image.sh"), "localhost/searxng:validated"], env={**os.environ, "PATH": str(tmp_path) + ":" + os.environ["PATH"], "DOCKER_CALLS": str(calls), "DOCKER_FAILURE": failure, "SEARXNG_SECRET": "operator-secret-must-not-propagate"}, capture_output=True, text=True)
assert result.returncode == expected, result.stderr
entries = [json.loads(line) for line in calls.read_text().splitlines()]
assert all(entry["secret"] == "searxng-ci-validation-only" for entry in entries)
commands = [entry["args"] for entry in entries]
if failure == "existing-volume":
assert len(commands) == 1
return
up = next(command for command in commands if "up" in command)
assert up[up.index("--env-file") + 1] == "/dev/null"
assert "--no-deps" in up and "--no-build" in up and "--wait" in up
assert up[up.index("--pull") + 1] == "never"
down = next(command for command in commands if "down" in command)
assert "--volumes" not in down
assert commands[-1] == ["volume", "rm", entries[-1]["volume"]]
if failure != "up":
execute = next(command for command in commands if "exec" in command)
assert execute[-2:] == ["--running", "--live"]
+6 -3
View File
@@ -255,7 +255,8 @@ async def test_read_file_dispatch_blocks_etc_shadow(monkeypatch):
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert "outside the sealed roots" in (result.get("error") or "")
assert result.get("blocked") is True
assert result.get("exit_code") == 1
@@ -285,7 +286,8 @@ async def test_write_file_dispatch_blocks_authorized_keys(monkeypatch):
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert "outside the sealed roots" in (result.get("error") or "")
assert result.get("blocked") is True
assert result.get("exit_code") == 1
@@ -349,7 +351,8 @@ async def test_write_file_dispatch_blocks_cron(monkeypatch):
security_context=NO_TOOL_SECURITY_CONTEXT,
)
assert result.get("failure_kind") == "resource_identity_denied"
assert "sealed resource root" in (result.get("error") or "")
assert "outside the sealed roots" in (result.get("error") or "")
assert result.get("blocked") is True
assert result.get("exit_code") == 1
@pytest.mark.parametrize("filename", ["auth.json", "app.db", "settings.json"])
def test_application_secrets_are_sensitive_paths(filename):