hecks 3.11.0 → 3.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/hecks/adapters/driving/cli_runner/resolution.rb +6 -3
- data/lib/hecks/adapters/driving/cli_runner.rb +1 -0
- data/lib/hecks/adapters/driving/newcomer.rb +50 -0
- data/lib/hecks/bluebook/assembly/contracts/read_models.rb +1 -1
- data/lib/hecks/bluebook/assembly/marks.rb +3 -15
- data/lib/hecks/bluebook/assembly/scalars.rb +39 -0
- data/lib/hecks/bluebook/meta_validator/readings.rb +6 -0
- data/lib/hecks/bluebook/meta_validator/reconstruction.rb +6 -3
- data/lib/hecks/bluebook.rb +1 -0
- data/lib/hecks/deploy/bluebook/deploy.bluebook +16 -2
- data/lib/hecks/gate/stages.yml +32 -0
- data/lib/hecks/hecks/adapters/rehearsal/postgres_store.rb +5 -1
- data/lib/hecks/hecks/hecks.world +3 -0
- data/lib/hecks/language/bluebook/bluebook.behaviors +23 -0
- data/lib/hecks/language/bluebook/policy.behaviors +19 -0
- data/lib/hecks/language/bluebook/vocabulary.bluebook +52 -0
- data/lib/hecks/projection_files/expression_tables.rb +7 -2
- data/lib/hecks/projections/deploy/box/compose.rb +6 -4
- data/lib/hecks/projections/deploy/box/edge.rb +62 -0
- data/lib/hecks/projections/deploy/box/logging.rb +99 -0
- data/lib/hecks/projections/deploy/box/roll_scripts.rb +16 -8
- data/lib/hecks/projections/deploy/box/settings/cdn.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/logs.rb +40 -0
- data/lib/hecks/projections/deploy/box/settings/patterns.rb +11 -0
- data/lib/hecks/projections/deploy/box/settings/types.rb +16 -1
- data/lib/hecks/projections/deploy/box/settings.rb +6 -2
- data/lib/hecks/projections/deploy/box/stack_yaml.rb +5 -2
- data/lib/hecks/projections/deploy/box/templates/Makefile.tmpl +16 -0
- data/lib/hecks/projections/deploy/box/templates/box.yaml.tmpl +8 -4
- data/lib/hecks/projections/deploy/box/templates/deploy-box.sh.tmpl +3 -0
- data/lib/hecks/projections/deploy/box/templates/edge.mk.tmpl +17 -0
- data/lib/hecks/projections/deploy/box/templates/edge.yaml.tmpl +65 -0
- data/lib/hecks/projections/deploy/box/templates/log-group.yaml.tmpl +12 -0
- data/lib/hecks/projections/deploy/box/templates/log-policy.yaml.tmpl +7 -0
- data/lib/hecks/projections/deploy/box/templates/render-compose-taskdef.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box/templates/render-compose.sh.tmpl +5 -4
- data/lib/hecks/projections/deploy/box.rb +15 -3
- data/lib/hecks/projections/site/site_root/workflow.rb +5 -0
- data/lib/hecks/projector/cli_audience.rb +20 -0
- data/lib/hecks/projector/cli_projector/usage.rb +15 -4
- data/lib/hecks/rust_build/host.rb +11 -4
- data/lib/hecks/rust_build/prebuilt_binary.rb +101 -0
- data/lib/hecks/tools/ci_gate_decision.rb +35 -0
- data/lib/hecks/tools/ci_gates.rb +20 -1
- data/lib/hecks/tools/lanes/files.rb +8 -0
- data/lib/hecks/tools/lanes/prebuilt_workflow.rb +81 -0
- data/lib/hecks/tools/lanes.rb +1 -0
- data/lib/hecks/tools/release_only.rb +111 -0
- data/lib/hecks/version.rb +1 -1
- data/lib/hecks/vocabulary.rb +15 -1
- data/prebuilt/9ee54e2f0d4d6d3fe046827779b221e02de665fba911a048ae5dd8b8fbfb7d90.marshal +0 -0
- data/prebuilt/d74a00e996222c1d8b676d5959092effc46b4dc59e0555f3bf9554ac58e3094a.marshal +0 -0
- data/prebuilt/{verdicts-230ac18c25abfd3d92ede032bee2661a4c2cd2773ab0cf0e2ae52546ba2f7af9.json → verdicts-df19f35868d3284a70f55e42dcef87ef4e65e6a678e5135f72e1f6cff4c1357b.json} +1 -1
- data/rust/host/HECKS_RELEASE +1 -1
- data/rust/host/src/extension.rs +13 -1
- data/rust/host/src/lib.rs +1 -0
- data/rust/host/src/platform_signin/attack_tests.rs +1100 -0
- data/rust/host/src/platform_signin/config.rs +512 -0
- data/rust/host/src/platform_signin/cookie.rs +237 -0
- data/rust/host/src/platform_signin/extension.rs +190 -0
- data/rust/host/src/platform_signin/fetcher.rs +322 -0
- data/rust/host/src/platform_signin/handler.rs +251 -0
- data/rust/host/src/platform_signin/handler_tests.rs +554 -0
- data/rust/host/src/platform_signin/jti.rs +196 -0
- data/rust/host/src/platform_signin/jwks.rs +542 -0
- data/rust/host/src/platform_signin/jws.rs +249 -0
- data/rust/host/src/platform_signin/known_answer_tests.rs +237 -0
- data/rust/host/src/platform_signin/mapping_tests.rs +254 -0
- data/rust/host/src/platform_signin/mod.rs +75 -0
- data/rust/host/src/platform_signin/mode_tests.rs +382 -0
- data/rust/host/src/platform_signin/pages.rs +204 -0
- data/rust/host/src/platform_signin/query.rs +233 -0
- data/rust/host/src/platform_signin/session.rs +151 -0
- data/rust/host/src/platform_signin/settings.rs +385 -0
- data/rust/host/src/platform_signin/strict_json.rs +351 -0
- data/rust/host/src/platform_signin/test_support.rs +235 -0
- data/rust/host/src/platform_signin/ttl_tests.rs +94 -0
- data/rust/host/src/platform_signin/verify.rs +604 -0
- data/rust/host/src/platform_signin/wiring_support.rs +319 -0
- data/rust/host/src/web.rs +50 -6
- data/rust/parser/src/parse/value_object.rs +22 -10
- metadata +42 -4
- data/prebuilt/05c358d38d661a3774f813d49c66f5b68939af94432d66257a56f49ee2598994.marshal +0 -0
- data/prebuilt/26c40c61f09b09b11651c64668eda3ceb180f83cf2a3802d3033d2fd99ac841b.marshal +0 -0
|
@@ -0,0 +1,554 @@
|
|
|
1
|
+
//! The handler end to end with a fake member store, a fixed clock and the shared vectors.
|
|
2
|
+
|
|
3
|
+
use std::sync::Arc;
|
|
4
|
+
|
|
5
|
+
use serde_json::Value;
|
|
6
|
+
|
|
7
|
+
use super::cookie;
|
|
8
|
+
use super::handler::{fresh_value, CallbackRequest, SigninHandler};
|
|
9
|
+
use super::wiring_support::*;
|
|
10
|
+
use crate::auth;
|
|
11
|
+
|
|
12
|
+
const AUTHORIZE: &str = "https://issuer.example/sso/authorize?site=site-a&env=production";
|
|
13
|
+
|
|
14
|
+
fn started(log: Arc<Recorder>) -> (SigninHandler, Value) {
|
|
15
|
+
let handler =
|
|
16
|
+
vector_handler("alongside", log).with_random(scripted_random(&[NONCE_A, STATE_A]));
|
|
17
|
+
let response = handler.start();
|
|
18
|
+
(handler, response)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn admin() -> FakeMembers {
|
|
22
|
+
FakeMembers::resolving(Person::new("person@example.test", Some("Admin")))
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async fn finish(handler: &SigninHandler, step: &Value, members: &FakeMembers) -> Value {
|
|
26
|
+
handler.callback(&request_for(step), members).await
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
fn status(response: &Value) -> u64 {
|
|
30
|
+
response["statusCode"].as_u64().unwrap()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
fn page_has_code(response: &Value, code: &str) -> bool {
|
|
34
|
+
response["body"].as_str().unwrap_or("").contains(code)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
fn only_clears_the_cookie(response: &Value) -> bool {
|
|
38
|
+
cookies_of(response) == vec![cookie::clear_header()]
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ---- start ----
|
|
42
|
+
|
|
43
|
+
#[test]
|
|
44
|
+
fn start_redirects_to_the_exact_authorize_url() {
|
|
45
|
+
let (_, response) = started(Arc::new(Recorder::default()));
|
|
46
|
+
assert_eq!(status(&response), 302);
|
|
47
|
+
assert_eq!(
|
|
48
|
+
response["headers"]["location"],
|
|
49
|
+
format!("{AUTHORIZE}&nonce={NONCE_A}&state={STATE_A}")
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
#[test]
|
|
54
|
+
fn start_sets_the_exact_cookie_and_nothing_else() {
|
|
55
|
+
let (_, response) = started(Arc::new(Recorder::default()));
|
|
56
|
+
assert_eq!(cookies_of(&response), vec![format!("__Host-signin={NONCE_A}.{STATE_A}; Max-Age=600; Path=/; Secure; HttpOnly; SameSite=Lax")]);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
#[test]
|
|
60
|
+
fn start_is_never_cached() {
|
|
61
|
+
let (_, response) = started(Arc::new(Recorder::default()));
|
|
62
|
+
assert_eq!(response["headers"]["cache-control"], "no-store");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
#[test]
|
|
66
|
+
fn fresh_values_are_long_url_safe_and_different_every_time() {
|
|
67
|
+
let values: Vec<String> = (0..50).map(|_| fresh_value()).collect();
|
|
68
|
+
for value in &values {
|
|
69
|
+
assert!(value.len() >= 22 && value.len() <= 128, "{value}");
|
|
70
|
+
assert!(
|
|
71
|
+
value
|
|
72
|
+
.bytes()
|
|
73
|
+
.all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_'),
|
|
74
|
+
"{value}"
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
let unique: std::collections::HashSet<_> = values.iter().collect();
|
|
78
|
+
assert_eq!(unique.len(), values.len());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
#[test]
|
|
82
|
+
fn the_real_start_gives_nonce_and_state_that_differ() {
|
|
83
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
84
|
+
let response = handler.start();
|
|
85
|
+
let set = cookies_of(&response).remove(0);
|
|
86
|
+
let value = set
|
|
87
|
+
.split(';')
|
|
88
|
+
.next()
|
|
89
|
+
.unwrap()
|
|
90
|
+
.trim_start_matches("__Host-signin=")
|
|
91
|
+
.to_string();
|
|
92
|
+
let (nonce, state) = value.split_once('.').unwrap();
|
|
93
|
+
assert_ne!(nonce, state);
|
|
94
|
+
assert!(response["headers"]["location"]
|
|
95
|
+
.as_str()
|
|
96
|
+
.unwrap()
|
|
97
|
+
.contains(&format!("nonce={nonce}&state={state}")));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
#[test]
|
|
101
|
+
fn start_logs_neither_the_nonce_nor_the_state() {
|
|
102
|
+
let log = Arc::new(Recorder::default());
|
|
103
|
+
started(log.clone());
|
|
104
|
+
assert!(!log.text().contains(NONCE_A) && !log.text().contains(STATE_A));
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ---- callback: success ----
|
|
108
|
+
|
|
109
|
+
#[tokio::test]
|
|
110
|
+
async fn a_genuine_token_signs_the_member_in() {
|
|
111
|
+
let doc = vectors();
|
|
112
|
+
let log = Arc::new(Recorder::default());
|
|
113
|
+
let handler = vector_handler("alongside", log);
|
|
114
|
+
let response = finish(&handler, &single(&doc, "valid"), &admin()).await;
|
|
115
|
+
assert_eq!(status(&response), 302);
|
|
116
|
+
assert_eq!(response["headers"]["location"], "/admin.html");
|
|
117
|
+
let cookies = cookies_of(&response);
|
|
118
|
+
assert_eq!(cookies.len(), 2);
|
|
119
|
+
assert_eq!(cookies[1], cookie::clear_header());
|
|
120
|
+
let account = &cookies[0];
|
|
121
|
+
assert!(account.starts_with(&format!("{}=", auth::DEFAULT_ACCOUNT_COOKIE)));
|
|
122
|
+
let token = account
|
|
123
|
+
.split(';')
|
|
124
|
+
.next()
|
|
125
|
+
.unwrap()
|
|
126
|
+
.split_once('=')
|
|
127
|
+
.unwrap()
|
|
128
|
+
.1;
|
|
129
|
+
assert_eq!(
|
|
130
|
+
auth::verify_account_token(SECRET, token).as_deref(),
|
|
131
|
+
Some("person@example.test")
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#[tokio::test]
|
|
136
|
+
async fn the_account_cookie_carries_the_hosts_flags_and_lifetime() {
|
|
137
|
+
let doc = vectors();
|
|
138
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
139
|
+
let response = finish(&handler, &single(&doc, "valid"), &admin()).await;
|
|
140
|
+
let account = cookies_of(&response).remove(0);
|
|
141
|
+
let attributes: Vec<&str> = account.split("; ").skip(1).collect();
|
|
142
|
+
assert_eq!(
|
|
143
|
+
attributes,
|
|
144
|
+
[
|
|
145
|
+
"Path=/",
|
|
146
|
+
"HttpOnly",
|
|
147
|
+
"Secure",
|
|
148
|
+
"SameSite=Lax",
|
|
149
|
+
&format!("Max-Age={}", auth::SESSION_TTL_SECS)
|
|
150
|
+
]
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[tokio::test]
|
|
155
|
+
async fn the_success_redirect_is_not_cached_and_keeps_the_referrer_quiet() {
|
|
156
|
+
let doc = vectors();
|
|
157
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
158
|
+
let response = finish(&handler, &single(&doc, "valid"), &admin()).await;
|
|
159
|
+
assert_eq!(response["headers"]["cache-control"], "no-store");
|
|
160
|
+
assert_eq!(response["headers"]["referrer-policy"], "no-referrer");
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[tokio::test]
|
|
164
|
+
async fn the_session_is_minted_for_the_members_own_email() {
|
|
165
|
+
let doc = vectors();
|
|
166
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
167
|
+
let members = FakeMembers::resolving(Person::new("Member.Row@example.test", Some("Admin")));
|
|
168
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
169
|
+
let account = cookies_of(&response).remove(0);
|
|
170
|
+
let token = account
|
|
171
|
+
.split(';')
|
|
172
|
+
.next()
|
|
173
|
+
.unwrap()
|
|
174
|
+
.split_once('=')
|
|
175
|
+
.unwrap()
|
|
176
|
+
.1;
|
|
177
|
+
assert_eq!(
|
|
178
|
+
auth::verify_account_token(SECRET, token).as_deref(),
|
|
179
|
+
Some("Member.Row@example.test")
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ---- callback: the member mapping ----
|
|
184
|
+
|
|
185
|
+
#[tokio::test]
|
|
186
|
+
async fn a_resolved_identity_never_provisions() {
|
|
187
|
+
let doc = vectors();
|
|
188
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
189
|
+
let members = admin();
|
|
190
|
+
finish(&handler, &single(&doc, "valid"), &members).await;
|
|
191
|
+
assert_eq!(members.called(), ["identity_for", "session_for_identity"]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
#[tokio::test]
|
|
195
|
+
async fn an_unresolved_identity_provisions_by_email_with_the_tokens_identity() {
|
|
196
|
+
let doc = vectors();
|
|
197
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
198
|
+
let members = FakeMembers::provisioning(Person::new("person@example.test", Some("Admin")));
|
|
199
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
200
|
+
assert_eq!(status(&response), 302);
|
|
201
|
+
assert_eq!(
|
|
202
|
+
members.called().last().unwrap(),
|
|
203
|
+
"provision person@example.test https://idp.example idp-subject-0001"
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
#[tokio::test]
|
|
208
|
+
async fn nothing_resolving_and_nothing_to_link_is_google_unlinked_with_no_session() {
|
|
209
|
+
let doc = vectors();
|
|
210
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
211
|
+
let members = FakeMembers::default();
|
|
212
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
213
|
+
assert_eq!(status(&response), 403);
|
|
214
|
+
assert!(page_has_code(&response, "google_unlinked"));
|
|
215
|
+
assert!(only_clears_the_cookie(&response));
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
#[tokio::test]
|
|
219
|
+
async fn there_is_no_fallback_by_email_when_the_identity_does_not_resolve() {
|
|
220
|
+
let doc = vectors();
|
|
221
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
222
|
+
let mut members = FakeMembers::default();
|
|
223
|
+
members.linked.insert(
|
|
224
|
+
"identity-1".into(),
|
|
225
|
+
Person::new("person@example.test", Some("Admin")),
|
|
226
|
+
);
|
|
227
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
228
|
+
assert_eq!(status(&response), 403);
|
|
229
|
+
assert!(only_clears_the_cookie(&response));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
#[tokio::test]
|
|
233
|
+
async fn a_member_without_a_role_is_refused() {
|
|
234
|
+
let doc = vectors();
|
|
235
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
236
|
+
let members = FakeMembers::resolving(Person::new("person@example.test", None));
|
|
237
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
238
|
+
assert_eq!(status(&response), 403);
|
|
239
|
+
assert!(only_clears_the_cookie(&response));
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
#[tokio::test]
|
|
243
|
+
async fn a_disabled_or_unknown_member_the_store_does_not_return_is_refused() {
|
|
244
|
+
let doc = vectors();
|
|
245
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
246
|
+
let mut members = FakeMembers::default();
|
|
247
|
+
members.identities.insert(
|
|
248
|
+
("https://idp.example".into(), "idp-subject-0001".into()),
|
|
249
|
+
"identity-1".into(),
|
|
250
|
+
);
|
|
251
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
252
|
+
assert_eq!(status(&response), 403);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
#[tokio::test]
|
|
256
|
+
async fn a_store_fault_is_our_fault_not_a_refusal_and_still_clears_the_cookie() {
|
|
257
|
+
let doc = vectors();
|
|
258
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
259
|
+
let members = FakeMembers {
|
|
260
|
+
fail: true,
|
|
261
|
+
..FakeMembers::default()
|
|
262
|
+
};
|
|
263
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
264
|
+
assert_eq!(status(&response), 500);
|
|
265
|
+
assert!(page_has_code(&response, "platform_unavailable"));
|
|
266
|
+
assert!(only_clears_the_cookie(&response));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
#[tokio::test]
|
|
270
|
+
async fn role_drift_is_logged_and_nothing_is_written() {
|
|
271
|
+
let doc = vectors();
|
|
272
|
+
let log = Arc::new(Recorder::default());
|
|
273
|
+
let handler = vector_handler("alongside", log.clone());
|
|
274
|
+
let members = FakeMembers::resolving(Person::new("person@example.test", Some("Member")));
|
|
275
|
+
let response = finish(&handler, &single(&doc, "valid"), &members).await;
|
|
276
|
+
assert_eq!(status(&response), 302);
|
|
277
|
+
let drift = log
|
|
278
|
+
.fields_of("platform_signin_role_drift")
|
|
279
|
+
.expect("drift logged");
|
|
280
|
+
assert_eq!(drift["token_role"], "Admin");
|
|
281
|
+
assert_eq!(drift["local_role"], "Member");
|
|
282
|
+
assert_eq!(members.called(), ["identity_for", "session_for_identity"]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
#[tokio::test]
|
|
286
|
+
async fn no_drift_is_logged_when_the_roles_agree() {
|
|
287
|
+
let doc = vectors();
|
|
288
|
+
let log = Arc::new(Recorder::default());
|
|
289
|
+
let handler = vector_handler("alongside", log.clone());
|
|
290
|
+
finish(&handler, &single(&doc, "valid"), &admin()).await;
|
|
291
|
+
assert!(log.fields_of("platform_signin_role_drift").is_none());
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- callback: every vector through the handler ----
|
|
295
|
+
|
|
296
|
+
fn expected_code(refusal: &str) -> &'static str {
|
|
297
|
+
match refusal {
|
|
298
|
+
"missing_cookie" => "platform_cookie",
|
|
299
|
+
"state_mismatch" => "platform_state",
|
|
300
|
+
_ => "platform_token",
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
#[tokio::test]
|
|
305
|
+
async fn every_vector_gets_the_right_status_code_and_never_a_session_when_refused() {
|
|
306
|
+
let doc = vectors();
|
|
307
|
+
for vector in doc["vectors"].as_array().unwrap() {
|
|
308
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
309
|
+
let steps: Vec<Value> = match vector["type"].as_str().unwrap() {
|
|
310
|
+
"sequence" => vector["steps"].as_array().unwrap().clone(),
|
|
311
|
+
_ => vec![vector.clone()],
|
|
312
|
+
};
|
|
313
|
+
for step in steps {
|
|
314
|
+
let name = vector["name"].as_str().unwrap();
|
|
315
|
+
let response = finish(&handler, &step, &admin()).await;
|
|
316
|
+
if step["expect"] == "accept" {
|
|
317
|
+
assert_eq!(status(&response), 302, "{name}");
|
|
318
|
+
assert_eq!(cookies_of(&response).len(), 2, "{name}");
|
|
319
|
+
} else {
|
|
320
|
+
let refusal = step["refusal"].as_str().unwrap();
|
|
321
|
+
let params_fault = refusal == "malformed" && step["token"].is_null();
|
|
322
|
+
let code =
|
|
323
|
+
if params_fault || (step["state"].is_null() && refusal == "state_mismatch") {
|
|
324
|
+
"platform_params"
|
|
325
|
+
} else {
|
|
326
|
+
expected_code(refusal)
|
|
327
|
+
};
|
|
328
|
+
assert_eq!(status(&response), 400, "{name}");
|
|
329
|
+
assert!(page_has_code(&response, code), "{name}: wanted {code}");
|
|
330
|
+
assert!(only_clears_the_cookie(&response), "{name}");
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ---- callback: faults in the request ----
|
|
337
|
+
|
|
338
|
+
fn good() -> (Value, CallbackRequest) {
|
|
339
|
+
let doc = vectors();
|
|
340
|
+
let step = single(&doc, "valid");
|
|
341
|
+
let request = request_for(&step);
|
|
342
|
+
(step, request)
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async fn answer(request: CallbackRequest) -> (Value, FakeMembers) {
|
|
346
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
347
|
+
let members = admin();
|
|
348
|
+
(handler.callback(&request, &members).await, members)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
#[tokio::test]
|
|
352
|
+
async fn a_missing_cookie_is_refused_before_anything_else() {
|
|
353
|
+
let (_, mut request) = good();
|
|
354
|
+
request.cookie_pairs.clear();
|
|
355
|
+
let (response, members) = answer(request).await;
|
|
356
|
+
assert_eq!(status(&response), 400);
|
|
357
|
+
assert!(page_has_code(&response, "platform_cookie"));
|
|
358
|
+
assert!(only_clears_the_cookie(&response));
|
|
359
|
+
assert!(members.called().is_empty());
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
#[tokio::test]
|
|
363
|
+
async fn a_repeated_or_malformed_cookie_is_refused() {
|
|
364
|
+
let (_, request) = good();
|
|
365
|
+
let mut twice = request.clone();
|
|
366
|
+
twice.cookie_pairs.push(request.cookie_pairs[0].clone());
|
|
367
|
+
let mut junk = request.clone();
|
|
368
|
+
junk.cookie_pairs = vec!["__Host-signin=nodot".into()];
|
|
369
|
+
for bad in [twice, junk] {
|
|
370
|
+
let (response, _) = answer(bad).await;
|
|
371
|
+
assert!(page_has_code(&response, "platform_cookie"));
|
|
372
|
+
assert!(only_clears_the_cookie(&response));
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
#[tokio::test]
|
|
377
|
+
async fn missing_repeated_oversized_or_undecodable_parameters_are_refused() {
|
|
378
|
+
let (step, request) = good();
|
|
379
|
+
let token = encode(step["token"].as_str().unwrap());
|
|
380
|
+
let state = encode(step["state"].as_str().unwrap());
|
|
381
|
+
for query in [
|
|
382
|
+
format!("state={state}"),
|
|
383
|
+
format!("token={token}"),
|
|
384
|
+
format!("token={token}&token={token}&state={state}"),
|
|
385
|
+
format!("token={token}&state={state}&state={state}"),
|
|
386
|
+
format!("token={token}&state={state}&pad={}", "a".repeat(20_000)),
|
|
387
|
+
format!("token=%zz&state={state}"),
|
|
388
|
+
] {
|
|
389
|
+
let bad = CallbackRequest {
|
|
390
|
+
raw_query: query,
|
|
391
|
+
..request.clone()
|
|
392
|
+
};
|
|
393
|
+
let (response, members) = answer(bad).await;
|
|
394
|
+
assert_eq!(status(&response), 400);
|
|
395
|
+
assert!(page_has_code(&response, "platform_params"));
|
|
396
|
+
assert!(only_clears_the_cookie(&response));
|
|
397
|
+
assert!(members.called().is_empty());
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
#[tokio::test]
|
|
402
|
+
async fn a_replayed_token_is_refused_the_second_time() {
|
|
403
|
+
let doc = vectors();
|
|
404
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
405
|
+
let step = single(&doc, "valid");
|
|
406
|
+
assert_eq!(status(&finish(&handler, &step, &admin()).await), 302);
|
|
407
|
+
let second = finish(&handler, &step, &admin()).await;
|
|
408
|
+
assert_eq!(status(&second), 400);
|
|
409
|
+
assert!(page_has_code(&second, "platform_token"));
|
|
410
|
+
assert!(only_clears_the_cookie(&second));
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
#[tokio::test]
|
|
414
|
+
async fn an_expired_token_is_refused_by_the_clock_the_handler_was_given() {
|
|
415
|
+
let doc = vectors();
|
|
416
|
+
let now = doc["now"].as_i64().unwrap();
|
|
417
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()))
|
|
418
|
+
.with_clock(Box::new(move || now + 3600));
|
|
419
|
+
let response = finish(&handler, &single(&doc, "valid"), &admin()).await;
|
|
420
|
+
assert_eq!(status(&response), 400);
|
|
421
|
+
assert!(only_clears_the_cookie(&response));
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
#[tokio::test]
|
|
425
|
+
async fn the_member_store_is_not_asked_until_the_token_has_verified() {
|
|
426
|
+
let doc = vectors();
|
|
427
|
+
for name in [
|
|
428
|
+
"state_mismatch",
|
|
429
|
+
"nonce_mismatch",
|
|
430
|
+
"wrong_aud_1",
|
|
431
|
+
"wrong_env_1",
|
|
432
|
+
"expired",
|
|
433
|
+
"payload_tampered",
|
|
434
|
+
"cookie_absent",
|
|
435
|
+
] {
|
|
436
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
437
|
+
let members = admin();
|
|
438
|
+
let response = finish(&handler, &single(&doc, name), &members).await;
|
|
439
|
+
assert_eq!(status(&response), 400, "{name}");
|
|
440
|
+
assert!(members.called().is_empty(), "{name}");
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// ---- callback: logging ----
|
|
445
|
+
|
|
446
|
+
#[tokio::test]
|
|
447
|
+
async fn no_log_line_ever_holds_a_token_nonce_state_or_full_email() {
|
|
448
|
+
let doc = vectors();
|
|
449
|
+
let log = Arc::new(Recorder::default());
|
|
450
|
+
let handler = vector_handler("alongside", log.clone());
|
|
451
|
+
for name in [
|
|
452
|
+
"valid",
|
|
453
|
+
"valid",
|
|
454
|
+
"state_mismatch",
|
|
455
|
+
"nonce_mismatch",
|
|
456
|
+
"wrong_aud_1",
|
|
457
|
+
"payload_tampered",
|
|
458
|
+
"expired",
|
|
459
|
+
] {
|
|
460
|
+
let step = single(&doc, name);
|
|
461
|
+
finish(
|
|
462
|
+
&handler,
|
|
463
|
+
&step,
|
|
464
|
+
&FakeMembers::resolving(Person::new("person@example.test", Some("Member"))),
|
|
465
|
+
)
|
|
466
|
+
.await;
|
|
467
|
+
finish(&handler, &step, &FakeMembers::default()).await;
|
|
468
|
+
let text = log.text();
|
|
469
|
+
for secret in [
|
|
470
|
+
step["token"].as_str().unwrap_or("-"),
|
|
471
|
+
step["state"].as_str().unwrap_or("-"),
|
|
472
|
+
"Nn0nceNn0nceNn0nceAA",
|
|
473
|
+
"StateStateState0123456789",
|
|
474
|
+
"person@example.test",
|
|
475
|
+
] {
|
|
476
|
+
assert!(!text.contains(secret), "{name} leaked {secret}");
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
assert!(!log.events().is_empty());
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
#[tokio::test]
|
|
483
|
+
async fn a_refusal_is_logged_by_its_stable_code() {
|
|
484
|
+
let doc = vectors();
|
|
485
|
+
let log = Arc::new(Recorder::default());
|
|
486
|
+
let handler = vector_handler("alongside", log.clone());
|
|
487
|
+
finish(&handler, &single(&doc, "nonce_mismatch"), &admin()).await;
|
|
488
|
+
assert_eq!(
|
|
489
|
+
log.fields_of("platform_signin_refused").unwrap()["reason"],
|
|
490
|
+
"nonce_mismatch"
|
|
491
|
+
);
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
// ---- L6: attack mirror at the handler ----
|
|
495
|
+
|
|
496
|
+
#[test]
|
|
497
|
+
fn a_start_request_cannot_choose_where_it_goes() {
|
|
498
|
+
let (handler, plain) = started(Arc::new(Recorder::default()));
|
|
499
|
+
let _ = handler;
|
|
500
|
+
let location = plain["headers"]["location"].as_str().unwrap();
|
|
501
|
+
assert!(location.starts_with("https://issuer.example/sso/authorize?"));
|
|
502
|
+
assert!(!location.contains("evil"));
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
#[tokio::test]
|
|
506
|
+
async fn open_redirect_parameters_on_the_callback_are_ignored() {
|
|
507
|
+
let (step, request) = good();
|
|
508
|
+
let token = encode(step["token"].as_str().unwrap());
|
|
509
|
+
let state = encode(step["state"].as_str().unwrap());
|
|
510
|
+
let query = format!("return=https%3A%2F%2Fevil.example&token={token}&next=//evil.example&redirect_uri=https://evil.example&state={state}&callback=//e");
|
|
511
|
+
let (response, _) = answer(CallbackRequest {
|
|
512
|
+
raw_query: query,
|
|
513
|
+
..request
|
|
514
|
+
})
|
|
515
|
+
.await;
|
|
516
|
+
assert_eq!(response["headers"]["location"], "/admin.html");
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
#[tokio::test]
|
|
520
|
+
async fn a_cookie_planted_by_someone_else_does_not_match_the_victims_callback() {
|
|
521
|
+
let doc = vectors();
|
|
522
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
523
|
+
let mut request = request_for(&single(&doc, "valid"));
|
|
524
|
+
request.cookie_pairs = vec![format!("__Host-signin={NONCE_B}.{STATE_B}")];
|
|
525
|
+
let response = handler.callback(&request, &admin()).await;
|
|
526
|
+
assert_eq!(status(&response), 400);
|
|
527
|
+
assert!(page_has_code(&response, "platform_state"));
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
#[tokio::test]
|
|
531
|
+
async fn a_cookie_with_no_matching_state_param_is_refused() {
|
|
532
|
+
let doc = vectors();
|
|
533
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
534
|
+
let mut request = request_for(&single(&doc, "valid"));
|
|
535
|
+
request.raw_query = request
|
|
536
|
+
.raw_query
|
|
537
|
+
.split('&')
|
|
538
|
+
.filter(|p| !p.starts_with("state="))
|
|
539
|
+
.collect::<Vec<_>>()
|
|
540
|
+
.join("&");
|
|
541
|
+
let response = handler.callback(&request, &admin()).await;
|
|
542
|
+
assert_eq!(status(&response), 400);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
#[tokio::test]
|
|
546
|
+
async fn a_refusal_page_never_echoes_request_values() {
|
|
547
|
+
let doc = vectors();
|
|
548
|
+
let handler = vector_handler("alongside", Arc::new(Recorder::default()));
|
|
549
|
+
let mut request = request_for(&single(&doc, "valid"));
|
|
550
|
+
request.raw_query = "token=<script>alert(1)</script>&state=<b>x</b>".into();
|
|
551
|
+
let response = handler.callback(&request, &admin()).await;
|
|
552
|
+
let body = response["body"].as_str().unwrap();
|
|
553
|
+
assert!(!body.contains("alert") && !body.contains("<b>"));
|
|
554
|
+
}
|