diff options
Diffstat (limited to 'gitolfs3-server/src/authz.rs')
-rw-r--r-- | gitolfs3-server/src/authz.rs | 182 |
1 files changed, 182 insertions, 0 deletions
diff --git a/gitolfs3-server/src/authz.rs b/gitolfs3-server/src/authz.rs new file mode 100644 index 0000000..0674cef --- /dev/null +++ b/gitolfs3-server/src/authz.rs | |||
@@ -0,0 +1,182 @@ | |||
1 | use std::collections::HashSet; | ||
2 | |||
3 | use axum::http::{header, HeaderMap, StatusCode}; | ||
4 | use chrono::{DateTime, Utc}; | ||
5 | use gitolfs3_common::{generate_tag, Claims, Digest, Oid, Operation, SpecificClaims}; | ||
6 | |||
7 | use crate::{ | ||
8 | api::{make_error_resp, GitLfsErrorResponse, REPO_NOT_FOUND}, | ||
9 | config::AuthorizationConfig, | ||
10 | }; | ||
11 | |||
12 | pub struct Trusted(pub bool); | ||
13 | |||
14 | fn forwarded_from_trusted_host( | ||
15 | headers: &HeaderMap, | ||
16 | trusted: &HashSet<String>, | ||
17 | ) -> Result<bool, GitLfsErrorResponse<'static>> { | ||
18 | if let Some(forwarded_host) = headers.get("X-Forwarded-Host") { | ||
19 | if let Ok(forwarded_host) = forwarded_host.to_str() { | ||
20 | if trusted.contains(forwarded_host) { | ||
21 | return Ok(true); | ||
22 | } | ||
23 | } else { | ||
24 | return Err(make_error_resp( | ||
25 | StatusCode::NOT_FOUND, | ||
26 | "Invalid X-Forwarded-Host header", | ||
27 | )); | ||
28 | } | ||
29 | } | ||
30 | Ok(false) | ||
31 | } | ||
32 | |||
33 | pub fn authorize_batch( | ||
34 | conf: &AuthorizationConfig, | ||
35 | repo_path: &str, | ||
36 | public: bool, | ||
37 | operation: Operation, | ||
38 | headers: &HeaderMap, | ||
39 | ) -> Result<Trusted, GitLfsErrorResponse<'static>> { | ||
40 | // - No authentication required for downloading exported repos | ||
41 | // - When authenticated: | ||
42 | // - Download / upload over presigned URLs | ||
43 | // - When accessing over Tailscale: | ||
44 | // - No authentication required for downloading from any repo | ||
45 | |||
46 | let claims = VerifyClaimsInput { | ||
47 | specific_claims: SpecificClaims::BatchApi(operation), | ||
48 | repo_path, | ||
49 | }; | ||
50 | if !verify_claims(conf, &claims, headers)? { | ||
51 | return authorize_batch_unauthenticated(conf, public, operation, headers); | ||
52 | } | ||
53 | Ok(Trusted(true)) | ||
54 | } | ||
55 | |||
56 | fn authorize_batch_unauthenticated( | ||
57 | conf: &AuthorizationConfig, | ||
58 | public: bool, | ||
59 | operation: Operation, | ||
60 | headers: &HeaderMap, | ||
61 | ) -> Result<Trusted, GitLfsErrorResponse<'static>> { | ||
62 | let trusted = forwarded_from_trusted_host(headers, &conf.trusted_forwarded_hosts)?; | ||
63 | match operation { | ||
64 | Operation::Upload => { | ||
65 | // Trusted users can clone all repositories (by virtue of accessing the server via a | ||
66 | // trusted network). However, they can not push without proper authentication. Untrusted | ||
67 | // users who are also not authenticated should not need to know which repositories exists. | ||
68 | // Therefore, we tell untrusted && unauthenticated users that the repo doesn't exist, but | ||
69 | // tell trusted users that they need to authenticate. | ||
70 | if !trusted { | ||
71 | return Err(REPO_NOT_FOUND); | ||
72 | } | ||
73 | Err(make_error_resp( | ||
74 | StatusCode::FORBIDDEN, | ||
75 | "Authentication required to upload", | ||
76 | )) | ||
77 | } | ||
78 | Operation::Download => { | ||
79 | // Again, trusted users can see all repos. For untrusted users, we first need to check | ||
80 | // whether the repo is public before we authorize. If the user is untrusted and the | ||
81 | // repo isn't public, we just act like it doesn't even exist. | ||
82 | if !trusted { | ||
83 | if !public { | ||
84 | return Err(REPO_NOT_FOUND); | ||
85 | } | ||
86 | return Ok(Trusted(false)); | ||
87 | } | ||
88 | Ok(Trusted(true)) | ||
89 | } | ||
90 | } | ||
91 | } | ||
92 | |||
93 | pub fn authorize_get( | ||
94 | conf: &AuthorizationConfig, | ||
95 | repo_path: &str, | ||
96 | oid: Oid, | ||
97 | headers: &HeaderMap, | ||
98 | ) -> Result<(), GitLfsErrorResponse<'static>> { | ||
99 | let claims = VerifyClaimsInput { | ||
100 | specific_claims: SpecificClaims::Download(oid), | ||
101 | repo_path, | ||
102 | }; | ||
103 | if !verify_claims(conf, &claims, headers)? { | ||
104 | return Err(make_error_resp( | ||
105 | StatusCode::UNAUTHORIZED, | ||
106 | "Repository not found", | ||
107 | )); | ||
108 | } | ||
109 | Ok(()) | ||
110 | } | ||
111 | |||
112 | pub struct VerifyClaimsInput<'a> { | ||
113 | pub specific_claims: SpecificClaims, | ||
114 | pub repo_path: &'a str, | ||
115 | } | ||
116 | |||
117 | fn verify_claims( | ||
118 | conf: &AuthorizationConfig, | ||
119 | claims: &VerifyClaimsInput, | ||
120 | headers: &HeaderMap, | ||
121 | ) -> Result<bool, GitLfsErrorResponse<'static>> { | ||
122 | const INVALID_AUTHZ_HEADER: GitLfsErrorResponse = | ||
123 | make_error_resp(StatusCode::BAD_REQUEST, "Invalid authorization header"); | ||
124 | |||
125 | let Some(authz) = headers.get(header::AUTHORIZATION) else { | ||
126 | return Ok(false); | ||
127 | }; | ||
128 | let authz = authz.to_str().map_err(|_| INVALID_AUTHZ_HEADER)?; | ||
129 | let val = authz | ||
130 | .strip_prefix("Gitolfs3-Hmac-Sha256 ") | ||
131 | .ok_or(INVALID_AUTHZ_HEADER)?; | ||
132 | let (tag, expires_at) = val.split_once(' ').ok_or(INVALID_AUTHZ_HEADER)?; | ||
133 | let tag: Digest<32> = tag.parse().map_err(|_| INVALID_AUTHZ_HEADER)?; | ||
134 | let expires_at: i64 = expires_at.parse().map_err(|_| INVALID_AUTHZ_HEADER)?; | ||
135 | let expires_at = DateTime::<Utc>::from_timestamp(expires_at, 0).ok_or(INVALID_AUTHZ_HEADER)?; | ||
136 | let expected_tag = generate_tag( | ||
137 | Claims { | ||
138 | specific_claims: claims.specific_claims, | ||
139 | repo_path: claims.repo_path, | ||
140 | expires_at, | ||
141 | }, | ||
142 | &conf.key, | ||
143 | ) | ||
144 | .ok_or_else(|| make_error_resp(StatusCode::INTERNAL_SERVER_ERROR, "Internal server error"))?; | ||
145 | if tag != expected_tag { | ||
146 | return Err(INVALID_AUTHZ_HEADER); | ||
147 | } | ||
148 | |||
149 | Ok(true) | ||
150 | } | ||
151 | |||
152 | #[test] | ||
153 | fn test_validate_claims() { | ||
154 | use gitolfs3_common::Key; | ||
155 | |||
156 | let key = "00232f7a019bd34e3921ee6c5f04caf48a4489d1be5d1999038950a7054e0bfea369ce2becc0f13fd3c69f8af2384a25b7ac2d52eb52c33722f3c00c50d4c9c2"; | ||
157 | let key: Key = key.parse().unwrap(); | ||
158 | |||
159 | let claims = Claims { | ||
160 | expires_at: Utc::now() + std::time::Duration::from_secs(5 * 60), | ||
161 | repo_path: "lfs-test.git", | ||
162 | specific_claims: SpecificClaims::BatchApi(Operation::Download), | ||
163 | }; | ||
164 | let tag = generate_tag(claims, &key).unwrap(); | ||
165 | let header_value = format!( | ||
166 | "Gitolfs3-Hmac-Sha256 {tag} {}", | ||
167 | claims.expires_at.timestamp() | ||
168 | ); | ||
169 | |||
170 | let conf = AuthorizationConfig { | ||
171 | key, | ||
172 | trusted_forwarded_hosts: HashSet::new(), | ||
173 | }; | ||
174 | let verification_claims = VerifyClaimsInput { | ||
175 | repo_path: claims.repo_path, | ||
176 | specific_claims: claims.specific_claims, | ||
177 | }; | ||
178 | let mut headers = HeaderMap::new(); | ||
179 | headers.insert(header::AUTHORIZATION, header_value.try_into().unwrap()); | ||
180 | |||
181 | assert!(verify_claims(&conf, &verification_claims, &headers).unwrap()); | ||
182 | } | ||