diff --git a/apps/browser/src/autofill/services/autofill.service.ts b/apps/browser/src/autofill/services/autofill.service.ts index 6f953e68b9..1002ca9922 100644 --- a/apps/browser/src/autofill/services/autofill.service.ts +++ b/apps/browser/src/autofill/services/autofill.service.ts @@ -29,6 +29,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; import { FieldView } from "@bitwarden/common/vault/models/view/field.view"; import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { BrowserApi } from "../../platform/browser/browser-api"; import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service"; @@ -1095,7 +1096,7 @@ export default class AutofillService implements AutofillServiceInterface { fillFields.expYear.maxLength === 4 ) { if (expYear.length === 2) { - expYear = "20" + expYear; + expYear = normalizeExpiryYearFormat(expYear); } } else if ( this.fieldAttrsContain(fillFields.expYear, "yy") || @@ -1121,7 +1122,7 @@ export default class AutofillService implements AutofillServiceInterface { let partYear: string = null; if (fullYear.length === 2) { partYear = fullYear; - fullYear = "20" + fullYear; + fullYear = normalizeExpiryYearFormat(fullYear); } else if (fullYear.length === 4) { partYear = fullYear.substr(2, 2); } diff --git a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts index 1a944d5599..02654f37ef 100644 --- a/apps/browser/src/vault/popup/components/vault/add-edit.component.ts +++ b/apps/browser/src/vault/popup/components/vault/add-edit.component.ts @@ -23,6 +23,7 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction"; import { CipherType } from "@bitwarden/common/vault/enums"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -182,6 +183,11 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit { const { isFido2Session, sessionId, userVerification } = fido2SessionData; const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session; + // normalize card expiry year on save + if (this.cipher.type === this.cipherType.Card) { + this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear); + } + // TODO: Revert to use fido2 user verification service once user verification for passkeys is approved for production. // PM-4577 - https://github.com/bitwarden/clients/pull/8746 if ( diff --git a/libs/angular/src/vault/components/add-edit.component.ts b/libs/angular/src/vault/components/add-edit.component.ts index 909a905e9b..96589fd2b0 100644 --- a/libs/angular/src/vault/components/add-edit.component.ts +++ b/libs/angular/src/vault/components/add-edit.component.ts @@ -37,6 +37,7 @@ import { IdentityView } from "@bitwarden/common/vault/models/view/identity.view" import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { DialogService } from "@bitwarden/components"; import { PasswordRepromptService } from "@bitwarden/vault"; @@ -330,6 +331,11 @@ export class AddEditComponent implements OnInit, OnDestroy { return this.restore(); } + // normalize card expiry year on save + if (this.cipher.type === this.cipherType.Card) { + this.cipher.card.expYear = normalizeExpiryYearFormat(this.cipher.card.expYear); + } + if (this.cipher.name == null || this.cipher.name === "") { this.platformUtilsService.showToast( "error", diff --git a/libs/common/src/vault/models/view/card.view.ts b/libs/common/src/vault/models/view/card.view.ts index d83b2c6f0a..f3bf4e1fab 100644 --- a/libs/common/src/vault/models/view/card.view.ts +++ b/libs/common/src/vault/models/view/card.view.ts @@ -2,6 +2,7 @@ import { Jsonify } from "type-fest"; import { CardLinkedId as LinkedId } from "../../enums"; import { linkedFieldOption } from "../../linked-field-option.decorator"; +import { normalizeExpiryYearFormat } from "../../utils"; import { ItemView } from "./item.view"; @@ -65,17 +66,16 @@ export class CardView extends ItemView { } get expiration(): string { - if (!this.expMonth && !this.expYear) { + const normalizedYear = normalizeExpiryYearFormat(this.expYear); + + if (!this.expMonth && !normalizedYear) { return null; } let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__"; - exp += " / " + (this.expYear != null ? this.formatYear(this.expYear) : "____"); - return exp; - } + exp += " / " + (normalizedYear || "____"); - private formatYear(year: string): string { - return year.length === 2 ? "20" + year : year; + return exp; } static fromJSON(obj: Partial>): CardView { diff --git a/libs/common/src/vault/utils.spec.ts b/libs/common/src/vault/utils.spec.ts new file mode 100644 index 0000000000..1cb185cffd --- /dev/null +++ b/libs/common/src/vault/utils.spec.ts @@ -0,0 +1,74 @@ +import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; + +function getExpiryYearValueFormats(currentCentury: string) { + return [ + [-12, `${currentCentury}12`], + [0, `${currentCentury}00`], + [2043, "2043"], // valid year with a length of four should be taken directly + [24, `${currentCentury}24`], + [3054, "3054"], // valid year with a length of four should be taken directly + [31423524543, `${currentCentury}43`], + [4, `${currentCentury}04`], + [null, null], + [undefined, null], + ["-12", `${currentCentury}12`], + ["", null], + ["0", `${currentCentury}00`], + ["00", `${currentCentury}00`], + ["000", `${currentCentury}00`], + ["0000", `${currentCentury}00`], + ["00000", `${currentCentury}00`], + ["0234234", `${currentCentury}34`], + ["04", `${currentCentury}04`], + ["2043", "2043"], // valid year with a length of four should be taken directly + ["24", `${currentCentury}24`], + ["3054", "3054"], // valid year with a length of four should be taken directly + ["31423524543", `${currentCentury}43`], + ["4", `${currentCentury}04`], + ["aaaa", null], + ["adgshsfhjsdrtyhsrth", null], + ["agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", `${currentCentury}45`], + ]; +} + +describe("normalizeExpiryYearFormat", () => { + const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); + + const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury); + + expiryYearValueFormats.forEach(([inputValue, expectedValue]) => { + it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => { + const formattedValue = normalizeExpiryYearFormat(inputValue); + + expect(formattedValue).toEqual(expectedValue); + }); + }); + + describe("in the year 3107", () => { + const theDistantFuture = new Date(Date.UTC(3107, 1, 1)); + jest.spyOn(Date, "now").mockReturnValue(theDistantFuture.valueOf()); + + beforeAll(() => { + jest.useFakeTimers({ advanceTimers: true }); + jest.setSystemTime(theDistantFuture); + }); + + afterAll(() => { + jest.useRealTimers(); + }); + + const currentCentury = `${new Date(Date.now()).getFullYear()}`.slice(0, 2); + expect(currentCentury).toBe("31"); + + const expiryYearValueFormats = getExpiryYearValueFormats(currentCentury); + + expiryYearValueFormats.forEach(([inputValue, expectedValue]) => { + it(`should return '${expectedValue}' when '${inputValue}' is passed`, () => { + const formattedValue = normalizeExpiryYearFormat(inputValue); + + expect(formattedValue).toEqual(expectedValue); + }); + }); + jest.clearAllTimers(); + }); +}); diff --git a/libs/common/src/vault/utils.ts b/libs/common/src/vault/utils.ts new file mode 100644 index 0000000000..7fed4abc12 --- /dev/null +++ b/libs/common/src/vault/utils.ts @@ -0,0 +1,42 @@ +type NonZeroIntegers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; +type Year = `${NonZeroIntegers}${NonZeroIntegers}${0 | NonZeroIntegers}${0 | NonZeroIntegers}`; + +/** + * Takes a string or number value and returns a string value formatted as a valid 4-digit year + * + * @export + * @param {(string | number)} yearInput + * @return {*} {(Year | null)} + */ +export function normalizeExpiryYearFormat(yearInput: string | number): Year | null { + // The input[type="number"] is returning a number, convert it to a string + // An empty field returns null, avoid casting `"null"` to a string + const yearInputIsEmpty = yearInput == null || yearInput === ""; + let expirationYear = yearInputIsEmpty ? null : `${yearInput}`; + + // Exit early if year is already formatted correctly or empty + if (yearInputIsEmpty || /^[1-9]{1}\d{3}$/.test(expirationYear)) { + return expirationYear as Year; + } + + expirationYear = expirationYear + // For safety, because even input[type="number"] will allow decimals + .replace(/[^\d]/g, "") + // remove any leading zero padding (leave the last leading zero if it ends the string) + .replace(/^[0]+(?=.)/, ""); + + if (expirationYear === "") { + expirationYear = null; + } + + // given the context of payment card expiry, a year character length of 3, or over 4 + // is more likely to be a mistake than an intentional value for the far past or far future. + if (expirationYear && expirationYear.length !== 4) { + const paddedYear = ("00" + expirationYear).slice(-2); + const currentCentury = `${new Date().getFullYear()}`.slice(0, 2); + + expirationYear = currentCentury + paddedYear; + } + + return expirationYear as Year | null; +} diff --git a/libs/importer/src/importers/base-importer.ts b/libs/importer/src/importers/base-importer.ts index f50044c633..215210eda1 100644 --- a/libs/importer/src/importers/base-importer.ts +++ b/libs/importer/src/importers/base-importer.ts @@ -11,6 +11,7 @@ import { FolderView } from "@bitwarden/common/vault/models/view/folder.view"; import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view"; import { LoginView } from "@bitwarden/common/vault/models/view/login.view"; import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.view"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { ImportResult } from "../models/import-result"; @@ -263,7 +264,8 @@ export abstract class BaseImporter { cipher.card.expMonth = expiryMatch.groups.month; const year: string = expiryMatch.groups.year; - cipher.card.expYear = year.length === 2 ? "20" + year : year; + cipher.card.expYear = normalizeExpiryYearFormat(year); + return true; } diff --git a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts index a80954a044..df45bcbcac 100644 --- a/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts +++ b/libs/vault/src/cipher-form/components/card-details-section/card-details-section.component.ts @@ -7,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module"; import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service"; import { CardView } from "@bitwarden/common/vault/models/view/card.view"; import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view"; +import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils"; import { CardComponent, FormFieldModule, @@ -101,9 +102,7 @@ export class CardDetailsSectionComponent implements OnInit { .pipe(takeUntilDestroyed()) .subscribe(({ cardholderName, number, brand, expMonth, expYear, code }) => { this.cipherFormContainer.patchCipher((cipher) => { - // The input[type="number"] is returning a number, convert it to a string - // An empty field returns null, avoid casting `"null"` to a string - const expirationYear = expYear !== null ? `${expYear}` : null; + const expirationYear = normalizeExpiryYearFormat(expYear); Object.assign(cipher.card, { cardholderName,