Skip to content

[PM-41690] fix: CXF import crash on negative timestamps - #1362

Open
crosenth wants to merge 4 commits into
bitwarden:mainfrom
crosenth:main
Open

[PM-41690] fix: CXF import crash on negative timestamps#1362
crosenth wants to merge 4 commits into
bitwarden:mainfrom
crosenth:main

Conversation

@crosenth

@crosenth crosenth commented Aug 7, 2026

Copy link
Copy Markdown

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

  • Sanitize negative creationAt/modifiedAt values in CXF payloads
    before deserialization, clamping them to 0
  • Fixes import failures from Google Password Manager on devices where
    credentials have no real creation/modification date (exported as the
    Windows FILETIME epoch: -11644473600)

Root cause

The credential-exchange-format crate defines these fields as
Option<u64>, which cannot represent negative values. The fix
pre-processes the JSON in parse_cxf() before the typed
deserialization step.

Test plan

  • Added unit tests for sanitize_timestamps() covering negative,
    valid, and mixed timestamp values
  • Added integration test confirming parse_cxf() succeeds with
    negative timestamps in the payload

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.
@crosenth
crosenth requested a review from a team as a code owner August 7, 2026 16:02
@CLAassistant

CLAassistant commented Aug 7, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@bitwarden-bot

Copy link
Copy Markdown
Collaborator

Thank you for your contribution! We've added this to our internal tracking system for review.
ID: PM-41690
Link: https://bitwarden.atlassian.net/browse/PM-41690

Details on our contribution process can be found here: https://contributing.bitwarden.com/contributing/pull-requests/community-pr-process.

@harr1424 harr1424 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎨 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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @harr1424 - Merged in your updates and fixed the null instead of 0 issue

Comment thread crates/bitwarden-exporters/src/cxf/import.rs Outdated
crosenth and others added 2 commits August 8, 2026 15:27
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
@harr1424
harr1424 self-requested a review August 8, 2026 22:37
// When no modification is needed, should return a borrowed reference to the original
assert_eq!(result.as_ref(), input);
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@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));
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants