1use crate::error::{ApiError, ApiErrorKind, ApiResult};
2use crate::headers::crypto_key::CryptoKeyHeader;
3use crate::headers::util::{get_header, get_owned_header};
4use actix_web::HttpRequest;
5use autopush_common::util::InsertOpt;
6use lazy_static::lazy_static;
7use regex::Regex;
8use std::cmp::min;
9use std::collections::HashMap;
10use std::time::Duration;
11use validator::Validate;
12use validator_derive::Validate;
13
14lazy_static! {
15 static ref VALID_BASE64_URL: Regex = Regex::new(r"^[0-9A-Za-z\-_]+=*$").unwrap();
16 static ref STRIP_PADDING: Regex =
17 Regex::new(r"(?P<head>[0-9A-Za-z\-_]+)=+(?P<tail>[,;]|$)").unwrap();
18}
19
20#[derive(Clone, Debug, Eq, PartialEq, Validate)]
22pub struct NotificationHeaders {
23 #[validate(range(min = 0, message = "TTL must be greater than 0", code = "114"))]
25 pub ttl: i64,
26
27 #[validate(
28 length(
29 max = 32,
30 message = "Topic must be no greater than 32 characters",
31 code = "113"
32 ),
33 regex(
34 path = *VALID_BASE64_URL,
35 message = "Topic must be URL and Filename safe Base64 alphabet",
36 code = "113"
37 )
38 )]
39 pub topic: Option<String>,
40
41 pub encoding: Option<String>,
44 pub encryption: Option<String>,
45 pub crypto_key: Option<String>,
46}
47
48impl From<NotificationHeaders> for HashMap<String, String> {
49 fn from(headers: NotificationHeaders) -> Self {
50 let mut map = HashMap::new();
51
52 map.insert_opt("encoding", headers.encoding);
53 map.insert_opt("encryption", headers.encryption);
54 map.insert_opt("crypto_key", headers.crypto_key);
55
56 map
57 }
58}
59
60impl NotificationHeaders {
61 pub fn from_request(
66 req: &HttpRequest,
67 has_data: bool,
68 max_notification_ttl: Duration,
69 ) -> ApiResult<Self> {
70 let ttl: i64 = get_header(req, "ttl")
72 .and_then(|ttl| ttl.parse().ok())
73 .map(|ttl: i64| min(ttl, max_notification_ttl.as_secs() as i64))
77 .ok_or(ApiErrorKind::NoTTL)?;
78
79 let topic = get_owned_header(req, "topic");
80
81 let headers = if has_data {
82 NotificationHeaders {
83 ttl,
84 topic,
85 encoding: get_owned_header(req, "content-encoding"),
86 encryption: get_owned_header(req, "encryption").map(Self::strip_header),
87 crypto_key: get_owned_header(req, "crypto-key").map(Self::strip_header),
88 }
89 } else {
90 NotificationHeaders {
92 ttl,
93 topic,
94 encoding: None,
95 encryption: None,
96 crypto_key: None,
97 }
98 };
99
100 if has_data {
102 headers.validate_encryption()?;
103 }
104
105 match headers.validate() {
107 Ok(_) => Ok(headers),
108 Err(e) => Err(ApiError::from(e)),
109 }
110 }
111
112 fn strip_header(header: String) -> String {
114 let header = header.replace('"', "");
115 STRIP_PADDING.replace_all(&header, "$head$tail").to_string()
116 }
117
118 fn validate_encryption(&self) -> ApiResult<()> {
121 let encoding = self.encoding.as_deref().ok_or_else(|| {
122 ApiErrorKind::InvalidEncryption("Missing Content-Encoding header".to_string())
123 })?;
124
125 match encoding {
126 "aesgcm" => self.validate_encryption_04_rules()?,
127 "aes128gcm" => self.validate_encryption_06_rules()?,
128 _ => {
129 return Err(ApiErrorKind::InvalidEncryption(
130 "Unknown Content-Encoding header".to_string(),
131 )
132 .into());
133 }
134 }
135
136 Ok(())
137 }
138
139 fn validate_encryption_04_rules(&self) -> ApiResult<()> {
142 Self::assert_base64_item_exists("Encryption", self.encryption.as_deref(), "salt")?;
143
144 if self.crypto_key.is_some() {
145 Self::assert_base64_item_exists("Crypto-Key", self.crypto_key.as_deref(), "dh")?;
146 }
147
148 Ok(())
149 }
150
151 fn validate_encryption_06_rules(&self) -> ApiResult<()> {
156 Self::assert_not_exists("aes128gcm Encryption", self.encryption.as_deref(), "salt")?;
157 Self::assert_not_exists("aes128gcm Crypto-Key", self.crypto_key.as_deref(), "dh")?;
158
159 Ok(())
160 }
161
162 fn assert_base64_item_exists(
164 header_name: &str,
165 header: Option<&str>,
166 key: &str,
167 ) -> ApiResult<()> {
168 let header = header.ok_or_else(|| {
169 ApiErrorKind::InvalidEncryption(format!("Missing {header_name} header"))
170 })?;
171 let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
172 ApiErrorKind::InvalidEncryption(format!("Invalid {header_name} header"))
173 })?;
174 let value = header_data.get_by_key(key).ok_or_else(|| {
175 ApiErrorKind::InvalidEncryption(format!("Missing {key} value in {header_name} header"))
176 })?;
177
178 if !VALID_BASE64_URL.is_match(value) {
179 return Err(ApiErrorKind::InvalidEncryption(format!(
180 "Invalid {key} value in {header_name} header",
181 ))
182 .into());
183 }
184
185 Ok(())
186 }
187
188 fn assert_not_exists(header_name: &str, header: Option<&str>, key: &str) -> ApiResult<()> {
190 let header = match header {
191 Some(header) => header,
192 None => return Ok(()),
193 };
194
195 let header_data = CryptoKeyHeader::parse(header).ok_or_else(|| {
196 ApiErrorKind::InvalidEncryption(format!("Invalid {header_name} header"))
197 })?;
198
199 if header_data.get_by_key(key).is_some() {
200 return Err(ApiErrorKind::InvalidEncryption(format!(
201 "Do not include '{key}' header in {header_name} header"
202 ))
203 .into());
204 }
205
206 Ok(())
207 }
208}
209
210#[cfg(test)]
211mod tests {
212 use std::time::Duration;
213
214 use super::NotificationHeaders;
215 use crate::error::{ApiErrorKind, ApiResult};
216 use actix_web::test::TestRequest;
217 use autopush_common::MAX_NOTIFICATION_TTL_SECS;
218
219 fn assert_validation_error(
222 result: ApiResult<NotificationHeaders>,
223 expected_json: serde_json::Value,
224 ) {
225 assert!(result.is_err());
226 let errors = match result.unwrap_err().kind {
227 ApiErrorKind::Validation(errors) => errors,
228 _ => panic!("Expected a validation error"),
229 };
230
231 assert_eq!(serde_json::to_value(errors).unwrap(), expected_json);
232 }
233
234 fn assert_encryption_error(result: ApiResult<NotificationHeaders>, expected_error: &str) {
236 assert!(result.is_err());
237 let error = match result.unwrap_err().kind {
238 ApiErrorKind::InvalidEncryption(error) => error,
239 _ => panic!("Expected an encryption error"),
240 };
241
242 assert_eq!(error, expected_error);
243 }
244
245 #[test]
247 fn valid_ttl() {
248 let req = TestRequest::post()
249 .insert_header(("TTL", "10"))
250 .to_http_request();
251 let result = NotificationHeaders::from_request(
252 &req,
253 false,
254 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
255 );
256
257 assert!(result.is_ok());
258 assert_eq!(result.unwrap().ttl, 10);
259 }
260
261 #[test]
263 fn negative_ttl() {
264 let req = TestRequest::post()
265 .insert_header(("TTL", "-1"))
266 .to_http_request();
267 let result = NotificationHeaders::from_request(
268 &req,
269 false,
270 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
271 );
272 assert_validation_error(
273 result,
274 serde_json::json!({
275 "ttl": [{
276 "code": "114",
277 "message": "TTL must be greater than 0",
278 "params": {
279 "min": 0,
280 "value": -1
281 }
282 }]
283 }),
284 );
285 }
286
287 #[test]
289 fn maximum_ttl() {
290 let req = TestRequest::post()
291 .insert_header(("TTL", (MAX_NOTIFICATION_TTL_SECS + 1).to_string()))
292 .to_http_request();
293 let result = NotificationHeaders::from_request(
294 &req,
295 false,
296 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
297 );
298
299 assert!(result.is_ok());
300 assert_eq!(result.unwrap().ttl, MAX_NOTIFICATION_TTL_SECS as i64);
301 }
302
303 #[test]
305 fn valid_topic() {
306 let req = TestRequest::post()
307 .insert_header(("TTL", "10"))
308 .insert_header(("TOPIC", "a-test-topic-which-is-just-right"))
309 .to_http_request();
310 let result = NotificationHeaders::from_request(
311 &req,
312 false,
313 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
314 );
315
316 assert!(result.is_ok());
317 assert_eq!(
318 result.unwrap().topic,
319 Some("a-test-topic-which-is-just-right".to_string())
320 );
321 }
322
323 #[test]
325 fn too_long_topic() {
326 let req = TestRequest::post()
327 .insert_header(("TTL", "10"))
328 .insert_header(("TOPIC", "test-topic-which-is-too-long-1234"))
329 .to_http_request();
330 let result = NotificationHeaders::from_request(
331 &req,
332 false,
333 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
334 );
335
336 assert_validation_error(
337 result,
338 serde_json::json!({
339 "topic": [{
340 "code": "113",
341 "message": "Topic must be no greater than 32 characters",
342 "params": {
343 "max": 32,
344 "value": "test-topic-which-is-too-long-1234"
345 }
346 }]
347 }),
348 );
349 }
350
351 #[test]
353 fn payload_without_content_encoding() {
354 let req = TestRequest::post()
355 .insert_header(("TTL", "10"))
356 .to_http_request();
357 let result = NotificationHeaders::from_request(
358 &req,
359 true,
360 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
361 );
362
363 assert_encryption_error(result, "Missing Content-Encoding header");
364 }
365
366 #[test]
368 fn valid_04_encryption() {
369 let req = TestRequest::post()
370 .insert_header(("TTL", "10"))
371 .insert_header(("Content-Encoding", "aesgcm"))
372 .insert_header(("Encryption", "salt=foo"))
373 .insert_header(("Crypto-Key", "dh=bar"))
374 .to_http_request();
375 let result = NotificationHeaders::from_request(
376 &req,
377 true,
378 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
379 );
380
381 assert!(result.is_ok());
382 assert_eq!(
383 result.unwrap(),
384 NotificationHeaders {
385 ttl: 10,
386 topic: None,
387 encoding: Some("aesgcm".to_string()),
388 encryption: Some("salt=foo".to_string()),
389 crypto_key: Some("dh=bar".to_string())
390 }
391 );
392 }
393
394 #[test]
396 fn valid_06_encryption() {
397 let req = TestRequest::post()
398 .insert_header(("TTL", "10"))
399 .insert_header(("Content-Encoding", "aes128gcm"))
400 .insert_header(("Encryption", "notsalt=foo"))
401 .insert_header(("Crypto-Key", "notdh=bar"))
402 .to_http_request();
403 let result = NotificationHeaders::from_request(
404 &req,
405 true,
406 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
407 );
408
409 assert!(result.is_ok());
410 assert_eq!(
411 result.unwrap(),
412 NotificationHeaders {
413 ttl: 10,
414 topic: None,
415 encoding: Some("aes128gcm".to_string()),
416 encryption: Some("notsalt=foo".to_string()),
417 crypto_key: Some("notdh=bar".to_string())
418 }
419 );
420 }
421
422 #[test]
425 fn strip_headers() {
426 let req = TestRequest::post()
427 .insert_header(("TTL", "10"))
428 .insert_header(("Content-Encoding", "aesgcm"))
429 .insert_header(("Encryption", "salt=\"foo\""))
430 .insert_header(("Crypto-Key", "keyid=\"p256dh\";dh=\"deadbeef==\""))
431 .to_http_request();
432 let result = NotificationHeaders::from_request(
433 &req,
434 true,
435 Duration::from_secs(MAX_NOTIFICATION_TTL_SECS),
436 );
437
438 assert!(result.is_ok());
439 assert_eq!(
440 result.unwrap(),
441 NotificationHeaders {
442 ttl: 10,
443 topic: None,
444 encoding: Some("aesgcm".to_string()),
445 encryption: Some("salt=foo".to_string()),
446 crypto_key: Some("keyid=p256dh;dh=deadbeef".to_string())
447 }
448 );
449 }
450
451 }