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,382 @@
|
|
|
1
|
+
//! The modes, the routes each owns, and the guarantee that off means off.
|
|
2
|
+
|
|
3
|
+
use std::sync::Arc;
|
|
4
|
+
|
|
5
|
+
use serde_json::{json, Value};
|
|
6
|
+
|
|
7
|
+
use super::extension::{extensions_for, PlatformSignin, Route};
|
|
8
|
+
use super::wiring_support::*;
|
|
9
|
+
use crate::extension::HostExtension;
|
|
10
|
+
|
|
11
|
+
fn event(method: &str, path: &str, query: &str) -> Value {
|
|
12
|
+
json!({"requestContext": {"http": {"method": method}}, "rawPath": path, "rawQueryString": query, "headers": {}})
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
fn extension(mode: &str) -> PlatformSignin {
|
|
16
|
+
PlatformSignin::from_env_map(&env_map(mode))
|
|
17
|
+
.unwrap()
|
|
18
|
+
.expect("on")
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
fn with_handler(mode: &str) -> PlatformSignin {
|
|
22
|
+
PlatformSignin::new(
|
|
23
|
+
vector_handler(mode, Arc::new(Recorder::default()))
|
|
24
|
+
.with_random(scripted_random(&[NONCE_A, STATE_A])),
|
|
25
|
+
)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async fn answer(ext: &PlatformSignin, event: &Value) -> Option<Value> {
|
|
29
|
+
ext.route_event(event, &FakeMembers::default()).await
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// Every route the host answers itself today, plus lookalikes.
|
|
33
|
+
const EXISTING: &[(&str, &str)] = &[
|
|
34
|
+
("GET", "/login"),
|
|
35
|
+
("POST", "/logout"),
|
|
36
|
+
("POST", "/accounts/logout"),
|
|
37
|
+
("GET", "/accounts/me"),
|
|
38
|
+
("GET", "/members"),
|
|
39
|
+
("POST", "/members"),
|
|
40
|
+
("POST", "/signups"),
|
|
41
|
+
("POST", "/members/disable"),
|
|
42
|
+
("POST", "/members/enable"),
|
|
43
|
+
("POST", "/members/role"),
|
|
44
|
+
("POST", "/members/delete"),
|
|
45
|
+
("GET", "/accounts/sso-token"),
|
|
46
|
+
("GET", "/auth/google/callback"),
|
|
47
|
+
("GET", "/admin/members"),
|
|
48
|
+
("POST", "/admin/members"),
|
|
49
|
+
("GET", "/"),
|
|
50
|
+
("GET", "/api/anything"),
|
|
51
|
+
("POST", "/newsletter/subscribers"),
|
|
52
|
+
("POST", "/registrations"),
|
|
53
|
+
("POST", "/webhooks/stripe"),
|
|
54
|
+
("GET", "/auth/platform/"),
|
|
55
|
+
("GET", "/auth/platformx"),
|
|
56
|
+
("GET", "/auth"),
|
|
57
|
+
("GET", "/auth/google/"),
|
|
58
|
+
("POST", "/auth/platform"),
|
|
59
|
+
("POST", "/auth/platform/callback"),
|
|
60
|
+
("PUT", "/auth/platform"),
|
|
61
|
+
("HEAD", "/auth/platform/callback"),
|
|
62
|
+
("GET", "/AUTH/PLATFORM"),
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
// ---- off means off ----
|
|
66
|
+
|
|
67
|
+
#[test]
|
|
68
|
+
fn an_unset_switch_installs_nothing() {
|
|
69
|
+
let mut vars = env_map("alongside");
|
|
70
|
+
vars.remove("PLATFORM_SIGNIN");
|
|
71
|
+
assert!(extensions_for(&vars).is_empty());
|
|
72
|
+
assert!(extensions_for(&Default::default()).is_empty());
|
|
73
|
+
assert!(PlatformSignin::from_env_map(&vars).unwrap().is_none());
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
#[test]
|
|
77
|
+
fn the_default_extension_list_is_unchanged_when_the_switch_is_unset() {
|
|
78
|
+
let installed = crate::extension::default_extensions_from(&Default::default());
|
|
79
|
+
assert_eq!(installed.len(), 1);
|
|
80
|
+
assert!(installed[0]
|
|
81
|
+
.rate_rules()
|
|
82
|
+
.iter()
|
|
83
|
+
.all(|rule| !rule.path.starts_with("/auth")));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
#[test]
|
|
87
|
+
fn a_switched_on_site_adds_exactly_one_extension() {
|
|
88
|
+
assert_eq!(
|
|
89
|
+
crate::extension::default_extensions_from(&env_map("default")).len(),
|
|
90
|
+
2
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
#[tokio::test]
|
|
95
|
+
async fn off_means_the_new_paths_reach_the_hosts_own_handling() {
|
|
96
|
+
// With no extension installed the loop in `web::render` finds nobody; this pins what a
|
|
97
|
+
// request for the new path meets instead: the hosts own routes, which know nothing of it.
|
|
98
|
+
let installed = crate::extension::default_extensions_from(&Default::default());
|
|
99
|
+
assert_eq!(installed.len(), 1);
|
|
100
|
+
assert!(installed
|
|
101
|
+
.iter()
|
|
102
|
+
.all(|e| e.rate_rules().iter().all(|r| r.path != "/auth/platform")));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ---- alongside ----
|
|
106
|
+
|
|
107
|
+
#[tokio::test]
|
|
108
|
+
async fn alongside_owns_the_two_platform_paths_and_leaves_google_alone() {
|
|
109
|
+
let ext = with_handler("alongside");
|
|
110
|
+
assert_eq!(
|
|
111
|
+
ext.classify(&event("GET", "/auth/platform", "")),
|
|
112
|
+
Some(Route::Start)
|
|
113
|
+
);
|
|
114
|
+
assert_eq!(
|
|
115
|
+
ext.classify(&event("GET", "/auth/platform/callback", "token=x")),
|
|
116
|
+
Some(Route::Callback)
|
|
117
|
+
);
|
|
118
|
+
for query in ["", "via=google", "via=other", "x=1"] {
|
|
119
|
+
assert_eq!(
|
|
120
|
+
ext.classify(&event("GET", "/auth/google", query)),
|
|
121
|
+
None,
|
|
122
|
+
"{query}"
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
assert!(answer(&ext, &event("GET", "/auth/google", ""))
|
|
126
|
+
.await
|
|
127
|
+
.is_none());
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#[tokio::test]
|
|
131
|
+
async fn alongside_start_goes_to_the_issuer() {
|
|
132
|
+
let response = answer(
|
|
133
|
+
&with_handler("alongside"),
|
|
134
|
+
&event("GET", "/auth/platform", ""),
|
|
135
|
+
)
|
|
136
|
+
.await
|
|
137
|
+
.unwrap();
|
|
138
|
+
assert_eq!(response["statusCode"], 302);
|
|
139
|
+
assert!(response["headers"]["location"]
|
|
140
|
+
.as_str()
|
|
141
|
+
.unwrap()
|
|
142
|
+
.starts_with("https://issuer.example/sso/authorize?"));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- default ----
|
|
146
|
+
|
|
147
|
+
#[tokio::test]
|
|
148
|
+
async fn default_sends_google_sign_in_to_the_issuer() {
|
|
149
|
+
let ext = with_handler("default");
|
|
150
|
+
assert_eq!(
|
|
151
|
+
ext.classify(&event("GET", "/auth/google", "")),
|
|
152
|
+
Some(Route::Start)
|
|
153
|
+
);
|
|
154
|
+
let response = answer(&ext, &event("GET", "/auth/google", ""))
|
|
155
|
+
.await
|
|
156
|
+
.unwrap();
|
|
157
|
+
assert!(response["headers"]["location"]
|
|
158
|
+
.as_str()
|
|
159
|
+
.unwrap()
|
|
160
|
+
.starts_with("https://issuer.example/sso/authorize?"));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#[test]
|
|
164
|
+
fn default_keeps_the_break_glass_to_the_existing_google_flow() {
|
|
165
|
+
let ext = with_handler("default");
|
|
166
|
+
for query in ["via=google", "a=1&via=google", "via=google&a=1"] {
|
|
167
|
+
assert_eq!(
|
|
168
|
+
ext.classify(&event("GET", "/auth/google", query)),
|
|
169
|
+
None,
|
|
170
|
+
"{query}"
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
#[test]
|
|
176
|
+
fn default_only_treats_the_exact_value_google_as_the_break_glass() {
|
|
177
|
+
let ext = with_handler("default");
|
|
178
|
+
for query in ["via=Google", "via=", "via=other", "via", "next=via=google"] {
|
|
179
|
+
assert_eq!(
|
|
180
|
+
ext.classify(&event("GET", "/auth/google", query)),
|
|
181
|
+
Some(Route::Start),
|
|
182
|
+
"{query}"
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[test]
|
|
188
|
+
fn default_also_answers_the_platform_paths() {
|
|
189
|
+
let ext = with_handler("default");
|
|
190
|
+
assert_eq!(
|
|
191
|
+
ext.classify(&event("GET", "/auth/platform", "")),
|
|
192
|
+
Some(Route::Start)
|
|
193
|
+
);
|
|
194
|
+
assert_eq!(
|
|
195
|
+
ext.classify(&event("GET", "/auth/platform/callback", "")),
|
|
196
|
+
Some(Route::Callback)
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#[test]
|
|
201
|
+
fn the_google_callback_is_never_taken_over() {
|
|
202
|
+
for mode in ["alongside", "default"] {
|
|
203
|
+
assert_eq!(
|
|
204
|
+
with_handler(mode).classify(&event("GET", "/auth/google/callback", "code=1&state=2")),
|
|
205
|
+
None
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ---- no other route is touched ----
|
|
211
|
+
|
|
212
|
+
#[tokio::test]
|
|
213
|
+
async fn no_existing_route_or_method_is_answered_in_either_mode() {
|
|
214
|
+
for mode in ["alongside", "default"] {
|
|
215
|
+
let ext = with_handler(mode);
|
|
216
|
+
for (method, path) in EXISTING {
|
|
217
|
+
assert!(
|
|
218
|
+
answer(&ext, &event(method, path, "via=google&x=1"))
|
|
219
|
+
.await
|
|
220
|
+
.is_none(),
|
|
221
|
+
"{mode} {method} {path}"
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
#[tokio::test]
|
|
228
|
+
async fn a_post_to_google_is_left_to_the_host_even_in_default_mode() {
|
|
229
|
+
assert!(
|
|
230
|
+
answer(&with_handler("default"), &event("POST", "/auth/google", ""))
|
|
231
|
+
.await
|
|
232
|
+
.is_none()
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
#[tokio::test]
|
|
237
|
+
async fn an_event_without_a_method_or_path_is_not_ours() {
|
|
238
|
+
let ext = with_handler("default");
|
|
239
|
+
assert!(answer(&ext, &json!({})).await.is_none());
|
|
240
|
+
assert!(answer(&ext, &json!({"rawPath": "/auth/platform"}))
|
|
241
|
+
.await
|
|
242
|
+
.is_none());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- boot refusals ----
|
|
246
|
+
|
|
247
|
+
#[test]
|
|
248
|
+
fn invalid_or_partial_settings_refuse_the_boot_naming_only_variables() {
|
|
249
|
+
let sentinel = "http://leaky-sentinel.example/x?secret=hunter2";
|
|
250
|
+
let mut cases: Vec<(&str, std::collections::HashMap<String, String>)> = Vec::new();
|
|
251
|
+
for name in [
|
|
252
|
+
"PLATFORM_ISSUER_URL",
|
|
253
|
+
"PLATFORM_SITE_ID",
|
|
254
|
+
"PLATFORM_ENVIRONMENT",
|
|
255
|
+
"SITE_URL",
|
|
256
|
+
"SESSION_SECRET",
|
|
257
|
+
"HECKS_SERVE_MODE",
|
|
258
|
+
] {
|
|
259
|
+
let mut missing = env_map("alongside");
|
|
260
|
+
missing.remove(name);
|
|
261
|
+
cases.push((name, missing));
|
|
262
|
+
let mut bad = env_map("alongside");
|
|
263
|
+
bad.insert(name.to_string(), sentinel.to_string());
|
|
264
|
+
// Any text is a usable secret, so only the other settings can be wrong in value.
|
|
265
|
+
if name != "SESSION_SECRET" {
|
|
266
|
+
cases.push((name, bad));
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
let mut bad_mode = env_map("alongside");
|
|
270
|
+
bad_mode.insert("PLATFORM_SIGNIN".into(), sentinel.into());
|
|
271
|
+
cases.push(("PLATFORM_SIGNIN", bad_mode));
|
|
272
|
+
for (variable, vars) in cases {
|
|
273
|
+
let installed = extensions_for(&vars);
|
|
274
|
+
assert_eq!(installed.len(), 1, "{variable}");
|
|
275
|
+
let message = installed[0]
|
|
276
|
+
.boot_check("anything")
|
|
277
|
+
.expect_err("the boot is refused");
|
|
278
|
+
assert!(message.contains(variable), "{variable}: {message}");
|
|
279
|
+
assert!(
|
|
280
|
+
!message.contains("sentinel")
|
|
281
|
+
&& !message.contains("hunter2")
|
|
282
|
+
&& !message.contains("a-long-session-secret"),
|
|
283
|
+
"{message}"
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
#[test]
|
|
289
|
+
fn a_good_configuration_passes_the_boot_check() {
|
|
290
|
+
let installed = extensions_for(&env_map("default"));
|
|
291
|
+
assert!(installed[0].boot_check("anything").is_ok());
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ---- rate rules ----
|
|
295
|
+
|
|
296
|
+
#[test]
|
|
297
|
+
fn the_start_and_the_callback_are_rate_limited_per_client() {
|
|
298
|
+
let rules = extension("alongside").rate_rules();
|
|
299
|
+
let find = |path: &str| {
|
|
300
|
+
rules
|
|
301
|
+
.iter()
|
|
302
|
+
.find(|r| r.path == path)
|
|
303
|
+
.unwrap_or_else(|| panic!("a rule for {path}"))
|
|
304
|
+
};
|
|
305
|
+
for path in ["/auth/platform", "/auth/platform/callback"] {
|
|
306
|
+
assert_eq!(find(path).method, "GET");
|
|
307
|
+
assert!(find(path).default_limit >= 10);
|
|
308
|
+
}
|
|
309
|
+
assert_ne!(
|
|
310
|
+
find("/auth/platform").name,
|
|
311
|
+
find("/auth/platform/callback").name
|
|
312
|
+
);
|
|
313
|
+
assert_ne!(
|
|
314
|
+
find("/auth/platform").limit_env,
|
|
315
|
+
find("/auth/platform/callback").limit_env
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
#[test]
|
|
320
|
+
fn the_break_glass_path_is_not_rate_limited_by_this_extension() {
|
|
321
|
+
assert!(extension("default")
|
|
322
|
+
.rate_rules()
|
|
323
|
+
.iter()
|
|
324
|
+
.all(|r| r.path != "/auth/google"));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---- hostile headers ----
|
|
328
|
+
|
|
329
|
+
#[tokio::test]
|
|
330
|
+
async fn host_and_forwarding_headers_change_nothing() {
|
|
331
|
+
let plain = event("GET", "/auth/platform", "");
|
|
332
|
+
let mut hostile = plain.clone();
|
|
333
|
+
hostile["headers"] = json!({
|
|
334
|
+
"host": "evil.example", "x-forwarded-host": "evil.example", "x-forwarded-proto": "http",
|
|
335
|
+
"x-forwarded-for": "203.0.113.9", "forwarded": "host=evil.example;proto=http", "origin": "https://evil.example",
|
|
336
|
+
"referer": "https://evil.example/"
|
|
337
|
+
});
|
|
338
|
+
let a = answer(&with_handler("alongside"), &plain).await.unwrap();
|
|
339
|
+
let b = answer(&with_handler("alongside"), &hostile).await.unwrap();
|
|
340
|
+
assert_eq!(a, b);
|
|
341
|
+
assert!(!a.to_string().contains("evil"));
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
#[tokio::test]
|
|
345
|
+
async fn a_hostile_host_on_the_callback_still_redirects_only_to_the_landing_page() {
|
|
346
|
+
let doc = vectors();
|
|
347
|
+
let step = single(&doc, "valid");
|
|
348
|
+
let request = request_for(&step);
|
|
349
|
+
let mut e = event("GET", "/auth/platform/callback", &request.raw_query);
|
|
350
|
+
e["headers"] = json!({"host": "evil.example", "x-forwarded-host": "evil.example", "cookie": request.cookie_pairs.join("; ")});
|
|
351
|
+
let members = FakeMembers::resolving(Person::new("person@example.test", Some("Admin")));
|
|
352
|
+
let ext = PlatformSignin::new(vector_handler("alongside", Arc::new(Recorder::default())));
|
|
353
|
+
let response = ext.route_event(&e, &members).await.unwrap();
|
|
354
|
+
assert_eq!(response["headers"]["location"], "/admin.html");
|
|
355
|
+
assert!(!response.to_string().contains("evil"));
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
#[tokio::test]
|
|
359
|
+
async fn a_repeated_sign_in_cookie_header_pair_is_refused_through_the_event() {
|
|
360
|
+
let doc = vectors();
|
|
361
|
+
let request = request_for(&single(&doc, "valid"));
|
|
362
|
+
let pair = request.cookie_pairs[0].clone();
|
|
363
|
+
let mut e = event("GET", "/auth/platform/callback", &request.raw_query);
|
|
364
|
+
e["headers"] = json!({"cookie": format!("{pair}; {pair}")});
|
|
365
|
+
let ext = PlatformSignin::new(vector_handler("alongside", Arc::new(Recorder::default())));
|
|
366
|
+
let response = ext.route_event(&e, &FakeMembers::default()).await.unwrap();
|
|
367
|
+
assert_eq!(response["statusCode"], 400);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
#[tokio::test]
|
|
371
|
+
async fn the_cookie_may_arrive_in_the_cookies_array_of_a_function_url_event() {
|
|
372
|
+
let doc = vectors();
|
|
373
|
+
let request = request_for(&single(&doc, "valid"));
|
|
374
|
+
let mut e = event("GET", "/auth/platform/callback", &request.raw_query);
|
|
375
|
+
e["cookies"] = json!(request.cookie_pairs);
|
|
376
|
+
let members = FakeMembers::resolving(Person::new("person@example.test", Some("Admin")));
|
|
377
|
+
let ext = PlatformSignin::new(vector_handler("alongside", Arc::new(Recorder::default())));
|
|
378
|
+
assert_eq!(
|
|
379
|
+
ext.route_event(&e, &members).await.unwrap()["statusCode"],
|
|
380
|
+
302
|
|
381
|
+
);
|
|
382
|
+
}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
//! The responses of the sign-in flow: the redirects it makes and the plain pages it refuses with.
|
|
2
|
+
//!
|
|
3
|
+
//! A refusal is a small static HTML page with a generic sentence and a stable code from a fixed
|
|
4
|
+
//! set. Nothing from the request is ever written into it, and no refusal redirects anywhere, so a
|
|
5
|
+
//! request cannot steer the browser. Every response to the callback, success or not, deletes the
|
|
6
|
+
//! sign-in cookie.
|
|
7
|
+
|
|
8
|
+
use serde_json::{json, Value};
|
|
9
|
+
|
|
10
|
+
use super::cookie;
|
|
11
|
+
|
|
12
|
+
/// Why a sign-in was refused, each with a stable code, a status and a generic sentence.
|
|
13
|
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
14
|
+
pub enum Failure {
|
|
15
|
+
/// The browser's sign-in cookie is missing, repeated or unreadable.
|
|
16
|
+
Cookie,
|
|
17
|
+
/// The callback's state is not the one this browser stored.
|
|
18
|
+
State,
|
|
19
|
+
/// The callback's query is missing a value, repeats one, or is unreadable.
|
|
20
|
+
Params,
|
|
21
|
+
/// The token was refused by the verifier, for any reason after the state.
|
|
22
|
+
Token,
|
|
23
|
+
/// The token is genuine but names nobody this site lets in.
|
|
24
|
+
Unlinked,
|
|
25
|
+
/// A fault on this side: the member store or the verification could not run.
|
|
26
|
+
Unavailable,
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
impl Failure {
|
|
30
|
+
/// Every code the flow can show, in a fixed order.
|
|
31
|
+
pub const ALL: [Failure; 6] = [
|
|
32
|
+
Failure::Cookie,
|
|
33
|
+
Failure::State,
|
|
34
|
+
Failure::Params,
|
|
35
|
+
Failure::Token,
|
|
36
|
+
Failure::Unlinked,
|
|
37
|
+
Failure::Unavailable,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
/// Returns the stable code shown on the page and logged.
|
|
41
|
+
pub fn code(self) -> &'static str {
|
|
42
|
+
match self {
|
|
43
|
+
Failure::Cookie => "platform_cookie",
|
|
44
|
+
Failure::State => "platform_state",
|
|
45
|
+
Failure::Params => "platform_params",
|
|
46
|
+
Failure::Token => "platform_token",
|
|
47
|
+
Failure::Unlinked => "google_unlinked",
|
|
48
|
+
Failure::Unavailable => "platform_unavailable",
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// Returns the HTTP status.
|
|
53
|
+
pub fn status(self) -> u16 {
|
|
54
|
+
match self {
|
|
55
|
+
Failure::Unlinked => 403,
|
|
56
|
+
Failure::Unavailable => 500,
|
|
57
|
+
_ => 400,
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fn message(self) -> &'static str {
|
|
62
|
+
match self {
|
|
63
|
+
Failure::Unlinked => "This account does not have access to this site.",
|
|
64
|
+
Failure::Unavailable => "Sign-in is unavailable. Please try again later.",
|
|
65
|
+
_ => "This sign-in link is not valid or has expired. Please start again from the site you came from.",
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/// Builds the refusal page, which also deletes the sign-in cookie.
|
|
71
|
+
pub fn refusal(failure: Failure) -> Value {
|
|
72
|
+
let body = format!(
|
|
73
|
+
"<!doctype html><html lang=\"en\"><head><meta charset=\"utf-8\"><title>Sign-in</title></head>\
|
|
74
|
+
<body><p>{}</p><p>Code: {}</p></body></html>\n",
|
|
75
|
+
failure.message(),
|
|
76
|
+
failure.code()
|
|
77
|
+
);
|
|
78
|
+
json!({
|
|
79
|
+
"statusCode": failure.status(),
|
|
80
|
+
"headers": page_headers(),
|
|
81
|
+
"cookies": [cookie::clear_header()],
|
|
82
|
+
"body": body,
|
|
83
|
+
"isBase64Encoded": false,
|
|
84
|
+
})
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
fn page_headers() -> Value {
|
|
88
|
+
json!({
|
|
89
|
+
"content-type": "text/html; charset=utf-8",
|
|
90
|
+
"cache-control": "no-store",
|
|
91
|
+
"referrer-policy": "no-referrer",
|
|
92
|
+
"x-content-type-options": "nosniff",
|
|
93
|
+
"content-security-policy": "default-src 'none'; frame-ancestors 'none'",
|
|
94
|
+
})
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/// Builds a redirect that carries `cookies` and is never cached.
|
|
98
|
+
pub fn redirect(location: &str, cookies: &[String]) -> Value {
|
|
99
|
+
json!({
|
|
100
|
+
"statusCode": 302,
|
|
101
|
+
"headers": {"location": location, "cache-control": "no-store", "referrer-policy": "no-referrer"},
|
|
102
|
+
"cookies": cookies,
|
|
103
|
+
"body": "",
|
|
104
|
+
"isBase64Encoded": false,
|
|
105
|
+
})
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
#[cfg(test)]
|
|
109
|
+
mod tests {
|
|
110
|
+
use super::*;
|
|
111
|
+
|
|
112
|
+
fn cookies(response: &Value) -> Vec<String> {
|
|
113
|
+
response["cookies"]
|
|
114
|
+
.as_array()
|
|
115
|
+
.unwrap()
|
|
116
|
+
.iter()
|
|
117
|
+
.map(|c| c.as_str().unwrap().to_string())
|
|
118
|
+
.collect()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
#[test]
|
|
122
|
+
fn codes_are_a_fixed_distinct_set() {
|
|
123
|
+
let codes: Vec<&str> = Failure::ALL.iter().map(|f| f.code()).collect();
|
|
124
|
+
assert_eq!(
|
|
125
|
+
codes,
|
|
126
|
+
[
|
|
127
|
+
"platform_cookie",
|
|
128
|
+
"platform_state",
|
|
129
|
+
"platform_params",
|
|
130
|
+
"platform_token",
|
|
131
|
+
"google_unlinked",
|
|
132
|
+
"platform_unavailable"
|
|
133
|
+
]
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#[test]
|
|
138
|
+
fn faults_in_the_request_are_400_and_a_refused_person_is_403_and_our_fault_is_500() {
|
|
139
|
+
let statuses: Vec<u16> = Failure::ALL.iter().map(|f| f.status()).collect();
|
|
140
|
+
assert_eq!(statuses, [400, 400, 400, 400, 403, 500]);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
#[test]
|
|
144
|
+
fn every_refusal_deletes_the_sign_in_cookie_and_nothing_else() {
|
|
145
|
+
for failure in Failure::ALL {
|
|
146
|
+
assert_eq!(
|
|
147
|
+
cookies(&refusal(failure)),
|
|
148
|
+
vec![cookie::clear_header()],
|
|
149
|
+
"{failure:?}"
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
#[test]
|
|
155
|
+
fn a_refusal_is_html_that_is_never_cached_framed_or_sniffed() {
|
|
156
|
+
let response = refusal(Failure::Token);
|
|
157
|
+
let headers = &response["headers"];
|
|
158
|
+
assert_eq!(response["statusCode"], 400);
|
|
159
|
+
assert_eq!(headers["content-type"], "text/html; charset=utf-8");
|
|
160
|
+
assert_eq!(headers["cache-control"], "no-store");
|
|
161
|
+
assert_eq!(headers["referrer-policy"], "no-referrer");
|
|
162
|
+
assert_eq!(headers["x-content-type-options"], "nosniff");
|
|
163
|
+
assert_eq!(
|
|
164
|
+
headers["content-security-policy"],
|
|
165
|
+
"default-src 'none'; frame-ancestors 'none'"
|
|
166
|
+
);
|
|
167
|
+
assert!(headers.get("location").is_none());
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
#[test]
|
|
171
|
+
fn the_page_shows_its_code_and_a_generic_sentence() {
|
|
172
|
+
for failure in Failure::ALL {
|
|
173
|
+
let body = refusal(failure)["body"].as_str().unwrap().to_string();
|
|
174
|
+
assert!(body.contains(failure.code()), "{body}");
|
|
175
|
+
assert!(body.starts_with("<!doctype html>"));
|
|
176
|
+
assert!(!body.contains("<script"));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
#[test]
|
|
181
|
+
fn a_refusal_never_redirects() {
|
|
182
|
+
for failure in Failure::ALL {
|
|
183
|
+
assert_ne!(refusal(failure)["statusCode"], 302);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
#[test]
|
|
188
|
+
fn a_redirect_is_a_302_with_no_store_and_its_cookies_in_order() {
|
|
189
|
+
let response = redirect("/admin.html", &["a=1".to_string(), "b=2".to_string()]);
|
|
190
|
+
assert_eq!(response["statusCode"], 302);
|
|
191
|
+
assert_eq!(response["headers"]["location"], "/admin.html");
|
|
192
|
+
assert_eq!(response["headers"]["cache-control"], "no-store");
|
|
193
|
+
assert_eq!(response["body"], "");
|
|
194
|
+
assert_eq!(cookies(&response), vec!["a=1", "b=2"]);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
#[test]
|
|
198
|
+
fn a_redirect_keeps_the_address_out_of_the_referrer() {
|
|
199
|
+
assert_eq!(
|
|
200
|
+
redirect("/x", &[])["headers"]["referrer-policy"],
|
|
201
|
+
"no-referrer"
|
|
202
|
+
);
|
|
203
|
+
}
|
|
204
|
+
}
|