[PM-41690] fix: CXF import crash on negative timestamps - #1362
Conversation
Some credential managers (e.g., Google Password Manager) export creationAt/modifiedAt as the Windows FILETIME epoch (-11644473600) when no real date exists. The credential-exchange-format crate deserializes these fields as u64 and rejects negative values. Sanitize the JSON payload before deserialization by clamping negative timestamp values to 0.
|
Thank you for your contribution! We've added this to our internal tracking system for review. Details on our contribution process can be found here: https://contributing.bitwarden.com/contributing/pull-requests/community-pr-process. |
harr1424
left a comment
There was a problem hiding this comment.
@crosenth This PR and the research you invested in it is greatly appreciated. I do disagree with the fundamental approach taken here, which diverges from v1.0 specifications:
creationAt
This OPTIONAL member contains the UNIX timestamp in seconds at which this Collection was originally created. If this member is not set, but the importing provider requires this member in their proprietary data model, the importer SHOULD use the current timestamp at the time the provider encounters this Collection.
modifiedAt
This OPTIONAL member contains the UNIX timestamp in seconds of the last modification brought to this Collection. If this member is not set, but the importing provider requires this member in their proprietary data model, the importer SHOULD use the current timestamp at the time the provider encounters this Collection.
The above also applies to these values nested in the items array.
Additionally, the fix has only been applied to negative values in the items array and not collection which can also introduce the negative values and cause a crash. Please see my comment in crates/bitwarden-exporters/src/cxf/tests/negative_timestamp_test.rs for a POC.
I've suggested code changes to improve the efficiency of the timestamp adjustment and ensure it results in the current timestamp.
I've also suggested some additional test coverage.
There was a problem hiding this comment.
🎨 Thanks for adding comprehensive test coverage. As described in another comment, this PR does not resolve the crash when these negative values are nested within a collections array as opposed to an items array:
#[test]
fn poc_collections_negative_timestamp_still_crashes() {
let input = r#"{
"id": "DZSXp7iBQY-Fg-OofakQtQ", "username": "u", "email": "e",
"collections": [{
"id": "DZSXp7iBQY-Fg-OofakQtQ",
"creationAt": -11644473600,
"modifiedAt": -11644473600,
"title": "C",
"items": []
}],
"items": []
}"#;
let result = parse_cxf(input.to_string());
assert!(result.is_ok(), "{:?}", result.err());
}🎨 If you agree with the code changes I've suggested in import.rs I would also suggest adding the following test coverage:
#[test]
fn test_sanitize_valid_timestamps_unchanged_returns_borrowed() {
let input = r#"{"id":"test","items":[{"id":"1","creationAt":1759783057,"modifiedAt":1759783057,"title":"Test","credentials":[]}]}"#;
let result = sanitize_timestamps(input);
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn test_sanitize_negative_timestamp_in_collection() {
let input = r#"{"id":"test","items":[],"collections":[{"id":"c1","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"C","items":[]}]}"#;
let result = sanitize_timestamps(input);
assert!(result.contains(r#""creationAt":null"#));
assert!(result.contains(r#""modifiedAt":null"#));
}
#[test]
fn test_sanitize_negative_timestamp_in_sub_collection() {
let input = r#"{"id":"test","items":[],"collections":[{"id":"c1","title":"C","items":[],
"subCollections":[{"id":"c2","creationAt":-11644473600,"title":"Sub","items":[]}]}]}"#;
let result = sanitize_timestamps(input);
assert!(result.contains(r#""creationAt":null"#));
}
#[test]
fn test_parse_cxf_with_negative_timestamps_falls_back_to_current_time() {
let input = r#"{
"id": "DZSXp7iBQY-Fg-OofakQtQ", "username": "u", "email": "e",
"collections": [{"id":"c1","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"C","items":[]}],
"items": [{
"id": "9OF-QjVDQo2Wp2xWPw6ZhA",
"creationAt": -11644473600, "modifiedAt": -11644473600,
"title": "Test Entry",
"credentials": [{"type":"basic-auth","username":{"id":"u","fieldType":"string","value":"testuser"},
"password":{"id":"p","fieldType":"concealed-string","value":"testpass"},
"urls":["https://example.com"]}]
}]
}"#;
let ciphers = parse_cxf(input.to_string()).expect("should not error on negative timestamps");
let cipher = ciphers.first().unwrap();
assert!(cipher.creation_date > Utc::now() - chrono::Duration::seconds(5));
}🎨 You will also want to update existing tests in this file to expect null instead of 0
There was a problem hiding this comment.
Thanks @harr1424 - Merged in your updates and fixed the null instead of 0 issue
Co-authored-by: John Harrington <84741727+harr1424@users.noreply.github.com>
- Assert null instead of 0 for clamped timestamps - Add test verifying unmodified input returns borrowed reference
| // When no modification is needed, should return a borrowed reference to the original | ||
| assert_eq!(result.as_ref(), input); | ||
| } | ||
|
|
There was a problem hiding this comment.
@crosenth This is a valuable test to add, but considering how the sanitize_timestamps() function was modified, I still think that adding the following tests from my original comment would be of value:
#[test]
fn test_sanitize_valid_timestamps_unchanged_returns_borrowed() {
let input = r#"{"id":"test","items":[{"id":"1","creationAt":1759783057,"modifiedAt":1759783057,"title":"Test","credentials":[]}]}"#;
let result = sanitize_timestamps(input);
assert!(matches!(result, std::borrow::Cow::Borrowed(_)));
}
#[test]
fn test_sanitize_negative_timestamp_in_collection() {
let input = r#"{"id":"test","items":[],"collections":[{"id":"c1","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"C","items":[]}]}"#;
let result = sanitize_timestamps(input);
assert!(result.contains(r#""creationAt":null"#));
assert!(result.contains(r#""modifiedAt":null"#));
}
#[test]
fn test_sanitize_negative_timestamp_in_sub_collection() {
let input = r#"{"id":"test","items":[],"collections":[{"id":"c1","title":"C","items":[],
"subCollections":[{"id":"c2","creationAt":-11644473600,"title":"Sub","items":[]}]}]}"#;
let result = sanitize_timestamps(input);
assert!(result.contains(r#""creationAt":null"#));
}
#[test]
fn test_parse_cxf_with_negative_timestamps_falls_back_to_current_time() {
let input = r#"{
"id": "DZSXp7iBQY-Fg-OofakQtQ", "username": "u", "email": "e",
"collections": [{"id":"c1","creationAt":-11644473600,"modifiedAt":-11644473600,"title":"C","items":[]}],
"items": [{
"id": "9OF-QjVDQo2Wp2xWPw6ZhA",
"creationAt": -11644473600, "modifiedAt": -11644473600,
"title": "Test Entry",
"credentials": [{"type":"basic-auth","username":{"id":"u","fieldType":"string","value":"testuser"},
"password":{"id":"p","fieldType":"concealed-string","value":"testpass"},
"urls":["https://example.com"]}]
}]
}"#;
let ciphers = parse_cxf(input.to_string()).expect("should not error on negative timestamps");
let cipher = ciphers.first().unwrap();
assert!(cipher.creation_date > Utc::now() - chrono::Duration::seconds(5));
}
Some credential managers (e.g., Google Password Manager) export creationAt/modifiedAt as the Windows FILETIME epoch (-11644473600) when no real date exists. The credential-exchange-format crate deserializes these fields as u64 and rejects negative values.
Sanitize the JSON payload before deserialization by clamping negative timestamp values to 0.
🎟️ Tracking
bitwarden/android#7140
bitwarden/android#7215
bitwarden/android#7216
https://bitwarden.atlassian.net/browse/PM-40542
📔 Objective
Summary
creationAt/modifiedAtvalues in CXF payloadsbefore deserialization, clamping them to 0
credentials have no real creation/modification date (exported as the
Windows FILETIME epoch: -11644473600)
Root cause
The
credential-exchange-formatcrate defines these fields asOption<u64>, which cannot represent negative values. The fixpre-processes the JSON in
parse_cxf()before the typeddeserialization step.
Test plan
sanitize_timestamps()covering negative,valid, and mixed timestamp values
parse_cxf()succeeds withnegative timestamps in the payload