| 993 |
993 |
|
);
|
| 994 |
994 |
|
}
|
| 995 |
995 |
|
|
|
996 |
+ |
// Suspension has to mean the same thing over HTTPS that it means over SSH.
|
|
997 |
+ |
// `git_ssh::dispatch` loads the user and refuses a suspended or deactivated
|
|
998 |
+ |
// account; the HTTPS funnel used to authenticate a token on its hash and expiry
|
|
999 |
+ |
// alone, so a token minted before a suspension kept working. Both credential
|
|
1000 |
+ |
// branches are covered here: the PAT and the session cookie.
|
|
1001 |
+ |
#[tokio::test]
|
|
1002 |
+ |
async fn suspended_account_loses_git_over_https() {
|
|
1003 |
+ |
let tmp = tempfile::TempDir::new().unwrap();
|
|
1004 |
+ |
make_test_repo(tmp.path());
|
|
1005 |
+ |
let mut h = setup_git_harness(&tmp).await;
|
|
1006 |
+ |
|
|
1007 |
+ |
h.client.get("/git/testowner/testrepo").await; // auto-register
|
|
1008 |
+ |
sqlx::query("UPDATE git_repos SET visibility = 'private' WHERE name = 'testrepo'")
|
|
1009 |
+ |
.execute(&h.db)
|
|
1010 |
+ |
.await
|
|
1011 |
+ |
.unwrap();
|
|
1012 |
+ |
|
|
1013 |
+ |
h.login("testowner", "password123").await;
|
|
1014 |
+ |
h.client.fetch_csrf_token().await;
|
|
1015 |
+ |
let resp = h
|
|
1016 |
+ |
.client
|
|
1017 |
+ |
.post_form("/api/users/me/git-tokens", "name=ci&can_push=on")
|
|
1018 |
+ |
.await;
|
|
1019 |
+ |
assert_eq!(resp.status, 200, "create push token: {}", resp.text);
|
|
1020 |
+ |
let token = resp.text.trim().to_string();
|
|
1021 |
+ |
|
|
1022 |
+ |
let upload = "/git/testowner/testrepo.git/info/refs?service=git-upload-pack";
|
|
1023 |
+ |
let recv = "/git/testowner/testrepo.git/info/refs?service=git-receive-pack";
|
|
1024 |
+ |
|
|
1025 |
+ |
// Baseline: the token reads and pushes, the cookie reads. The token half
|
|
1026 |
+ |
// runs with the cookies cleared, since the funnel prefers a session and a
|
|
1027 |
+ |
// session is not a push credential.
|
|
1028 |
+ |
h.client.clear_cookies();
|
|
1029 |
+ |
let resp = h
|
|
1030 |
+ |
.client
|
|
1031 |
+ |
.request_with_headers(
|
|
1032 |
+ |
"GET",
|
|
1033 |
+ |
upload,
|
|
1034 |
+ |
None,
|
|
1035 |
+ |
&[("Authorization", &basic_auth(&token))],
|
|
1036 |
+ |
)
|
|
1037 |
+ |
.await;
|
|
1038 |
+ |
assert_eq!(
|
|
1039 |
+ |
resp.status, 200,
|
|
1040 |
+ |
"token read before suspension: {}",
|
|
1041 |
+ |
resp.text
|
|
1042 |
+ |
);
|
|
1043 |
+ |
let resp = h
|
|
1044 |
+ |
.client
|
|
1045 |
+ |
.request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))])
|
|
1046 |
+ |
.await;
|
|
1047 |
+ |
assert_eq!(
|
|
1048 |
+ |
resp.status, 200,
|
|
1049 |
+ |
"token push advert before suspension: {}",
|
|
1050 |
+ |
resp.text
|
|
1051 |
+ |
);
|
|
1052 |
+ |
h.login("testowner", "password123").await;
|
|
1053 |
+ |
let resp = h.client.get(upload).await;
|
|
1054 |
+ |
assert_eq!(
|
|
1055 |
+ |
resp.status, 200,
|
|
1056 |
+ |
"cookie read before suspension: {}",
|
|
1057 |
+ |
resp.text
|
|
1058 |
+ |
);
|
|
1059 |
+ |
|
|
1060 |
+ |
sqlx::query("UPDATE users SET suspended_at = now() WHERE username = 'testowner'")
|
|
1061 |
+ |
.execute(&h.db)
|
|
1062 |
+ |
.await
|
|
1063 |
+ |
.unwrap();
|
|
1064 |
+ |
|
|
1065 |
+ |
// The cookie branch: the session survives, the account does not.
|
|
1066 |
+ |
let resp = h.client.get(upload).await;
|
|
1067 |
+ |
assert_eq!(
|
|
1068 |
+ |
resp.status, 404,
|
|
1069 |
+ |
"a suspended account must not read over a session cookie: {}",
|
|
1070 |
+ |
resp.text
|
|
1071 |
+ |
);
|
|
1072 |
+ |
|
|
1073 |
+ |
// The token branch, read and push both.
|
|
1074 |
+ |
h.client.clear_cookies();
|
|
1075 |
+ |
let resp = h
|
|
1076 |
+ |
.client
|
|
1077 |
+ |
.request_with_headers(
|
|
1078 |
+ |
"GET",
|
|
1079 |
+ |
upload,
|
|
1080 |
+ |
None,
|
|
1081 |
+ |
&[("Authorization", &basic_auth(&token))],
|
|
1082 |
+ |
)
|
|
1083 |
+ |
.await;
|
|
1084 |
+ |
assert_eq!(
|
|
1085 |
+ |
resp.status, 404,
|
|
1086 |
+ |
"a suspended account must not clone over HTTPS: {}",
|
|
1087 |
+ |
resp.text
|
|
1088 |
+ |
);
|
|
1089 |
+ |
let resp = h
|
|
1090 |
+ |
.client
|
|
1091 |
+ |
.request_with_headers("GET", recv, None, &[("Authorization", &basic_auth(&token))])
|
|
1092 |
+ |
.await;
|
|
1093 |
+ |
assert_eq!(
|
|
1094 |
+ |
resp.status, 404,
|
|
1095 |
+ |
"a suspended account must not push over HTTPS: {}",
|
|
1096 |
+ |
resp.text
|
|
1097 |
+ |
);
|
|
1098 |
+ |
|
|
1099 |
+ |
// Deactivation is refused on the same terms, the way SSH pairs them.
|
|
1100 |
+ |
sqlx::query(
|
|
1101 |
+ |
"UPDATE users SET suspended_at = NULL, deactivated_at = now() WHERE username = 'testowner'",
|
|
1102 |
+ |
)
|
|
1103 |
+ |
.execute(&h.db)
|
|
1104 |
+ |
.await
|
|
1105 |
+ |
.unwrap();
|
|
1106 |
+ |
let resp = h
|
|
1107 |
+ |
.client
|
|
1108 |
+ |
.request_with_headers(
|
|
1109 |
+ |
"GET",
|
|
1110 |
+ |
upload,
|
|
1111 |
+ |
None,
|
|
1112 |
+ |
&[("Authorization", &basic_auth(&token))],
|
|
1113 |
+ |
)
|
|
1114 |
+ |
.await;
|
|
1115 |
+ |
assert_eq!(
|
|
1116 |
+ |
resp.status, 404,
|
|
1117 |
+ |
"a deactivated account must not clone over HTTPS: {}",
|
|
1118 |
+ |
resp.text
|
|
1119 |
+ |
);
|
|
1120 |
+ |
|
|
1121 |
+ |
// Lifting the suspension restores the same token, so it is account standing
|
|
1122 |
+ |
// being enforced and not the token being invalidated.
|
|
1123 |
+ |
sqlx::query("UPDATE users SET deactivated_at = NULL WHERE username = 'testowner'")
|
|
1124 |
+ |
.execute(&h.db)
|
|
1125 |
+ |
.await
|
|
1126 |
+ |
.unwrap();
|
|
1127 |
+ |
let resp = h
|
|
1128 |
+ |
.client
|
|
1129 |
+ |
.request_with_headers(
|
|
1130 |
+ |
"GET",
|
|
1131 |
+ |
upload,
|
|
1132 |
+ |
None,
|
|
1133 |
+ |
&[("Authorization", &basic_auth(&token))],
|
|
1134 |
+ |
)
|
|
1135 |
+ |
.await;
|
|
1136 |
+ |
assert_eq!(
|
|
1137 |
+ |
resp.status, 200,
|
|
1138 |
+ |
"token should work again once the account is in good standing: {}",
|
|
1139 |
+ |
resp.text
|
|
1140 |
+ |
);
|
|
1141 |
+ |
}
|
|
1142 |
+ |
|
| 996 |
1143 |
|
// UX-S1: git push (receive-pack) must reject session-cookie auth, even for the
|
| 997 |
1144 |
|
// repo OWNER. These routes are merged outside the CsrfRouter/origin_gate tree, so
|
| 998 |
1145 |
|
// a cookie-authed push would be drivable cross-origin with only git wire-format
|