mirror of
https://github.com/bitwarden/browser
synced 2025-12-12 14:23:32 +00:00
[PM-11350] Use shared expiration year normalization util function (#10735)
* use shared expiration year normalization util function * use shared exp year normalization in web and desktop client * handle cases where input has leading zeroes * add utils tests * handle cases where input is all zeroes
This commit is contained in:
@@ -29,6 +29,7 @@ import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-repromp
|
|||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
import { FieldView } from "@bitwarden/common/vault/models/view/field.view";
|
||||||
import { IdentityView } from "@bitwarden/common/vault/models/view/identity.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 { BrowserApi } from "../../platform/browser/browser-api";
|
||||||
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
import { ScriptInjectorService } from "../../platform/services/abstractions/script-injector.service";
|
||||||
@@ -1095,7 +1096,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
fillFields.expYear.maxLength === 4
|
fillFields.expYear.maxLength === 4
|
||||||
) {
|
) {
|
||||||
if (expYear.length === 2) {
|
if (expYear.length === 2) {
|
||||||
expYear = "20" + expYear;
|
expYear = normalizeExpiryYearFormat(expYear);
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
this.fieldAttrsContain(fillFields.expYear, "yy") ||
|
this.fieldAttrsContain(fillFields.expYear, "yy") ||
|
||||||
@@ -1121,7 +1122,7 @@ export default class AutofillService implements AutofillServiceInterface {
|
|||||||
let partYear: string = null;
|
let partYear: string = null;
|
||||||
if (fullYear.length === 2) {
|
if (fullYear.length === 2) {
|
||||||
partYear = fullYear;
|
partYear = fullYear;
|
||||||
fullYear = "20" + fullYear;
|
fullYear = normalizeExpiryYearFormat(fullYear);
|
||||||
} else if (fullYear.length === 4) {
|
} else if (fullYear.length === 4) {
|
||||||
partYear = fullYear.substr(2, 2);
|
partYear = fullYear.substr(2, 2);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import { CollectionService } from "@bitwarden/common/vault/abstractions/collecti
|
|||||||
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
import { FolderService } from "@bitwarden/common/vault/abstractions/folder/folder.service.abstraction";
|
||||||
import { CipherType } from "@bitwarden/common/vault/enums";
|
import { CipherType } from "@bitwarden/common/vault/enums";
|
||||||
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
import { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||||
import { DialogService } from "@bitwarden/components";
|
import { DialogService } from "@bitwarden/components";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|
||||||
@@ -182,6 +183,11 @@ export class AddEditComponent extends BaseAddEditComponent implements OnInit {
|
|||||||
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
|
const { isFido2Session, sessionId, userVerification } = fido2SessionData;
|
||||||
const inFido2PopoutWindow = BrowserPopupUtils.inPopout(window) && isFido2Session;
|
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.
|
// 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
|
// PM-4577 - https://github.com/bitwarden/clients/pull/8746
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -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 { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.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 { DialogService } from "@bitwarden/components";
|
||||||
import { PasswordRepromptService } from "@bitwarden/vault";
|
import { PasswordRepromptService } from "@bitwarden/vault";
|
||||||
|
|
||||||
@@ -330,6 +331,11 @@ export class AddEditComponent implements OnInit, OnDestroy {
|
|||||||
return this.restore();
|
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 === "") {
|
if (this.cipher.name == null || this.cipher.name === "") {
|
||||||
this.platformUtilsService.showToast(
|
this.platformUtilsService.showToast(
|
||||||
"error",
|
"error",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Jsonify } from "type-fest";
|
|||||||
|
|
||||||
import { CardLinkedId as LinkedId } from "../../enums";
|
import { CardLinkedId as LinkedId } from "../../enums";
|
||||||
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
import { linkedFieldOption } from "../../linked-field-option.decorator";
|
||||||
|
import { normalizeExpiryYearFormat } from "../../utils";
|
||||||
|
|
||||||
import { ItemView } from "./item.view";
|
import { ItemView } from "./item.view";
|
||||||
|
|
||||||
@@ -65,17 +66,16 @@ export class CardView extends ItemView {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get expiration(): string {
|
get expiration(): string {
|
||||||
if (!this.expMonth && !this.expYear) {
|
const normalizedYear = normalizeExpiryYearFormat(this.expYear);
|
||||||
|
|
||||||
|
if (!this.expMonth && !normalizedYear) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__";
|
let exp = this.expMonth != null ? ("0" + this.expMonth).slice(-2) : "__";
|
||||||
exp += " / " + (this.expYear != null ? this.formatYear(this.expYear) : "____");
|
exp += " / " + (normalizedYear || "____");
|
||||||
return exp;
|
|
||||||
}
|
|
||||||
|
|
||||||
private formatYear(year: string): string {
|
return exp;
|
||||||
return year.length === 2 ? "20" + year : year;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static fromJSON(obj: Partial<Jsonify<CardView>>): CardView {
|
static fromJSON(obj: Partial<Jsonify<CardView>>): CardView {
|
||||||
|
|||||||
74
libs/common/src/vault/utils.spec.ts
Normal file
74
libs/common/src/vault/utils.spec.ts
Normal file
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
42
libs/common/src/vault/utils.ts
Normal file
42
libs/common/src/vault/utils.ts
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { LoginUriView } from "@bitwarden/common/vault/models/view/login-uri.view";
|
||||||
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
import { LoginView } from "@bitwarden/common/vault/models/view/login.view";
|
||||||
import { SecureNoteView } from "@bitwarden/common/vault/models/view/secure-note.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";
|
import { ImportResult } from "../models/import-result";
|
||||||
|
|
||||||
@@ -263,7 +264,8 @@ export abstract class BaseImporter {
|
|||||||
|
|
||||||
cipher.card.expMonth = expiryMatch.groups.month;
|
cipher.card.expMonth = expiryMatch.groups.month;
|
||||||
const year: string = expiryMatch.groups.year;
|
const year: string = expiryMatch.groups.year;
|
||||||
cipher.card.expYear = year.length === 2 ? "20" + year : year;
|
cipher.card.expYear = normalizeExpiryYearFormat(year);
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { JslibModule } from "@bitwarden/angular/jslib.module";
|
|||||||
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
import { I18nService } from "@bitwarden/common/platform/abstractions/i18n.service";
|
||||||
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
|
||||||
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
import { CipherView } from "@bitwarden/common/vault/models/view/cipher.view";
|
||||||
|
import { normalizeExpiryYearFormat } from "@bitwarden/common/vault/utils";
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
FormFieldModule,
|
FormFieldModule,
|
||||||
@@ -101,9 +102,7 @@ export class CardDetailsSectionComponent implements OnInit {
|
|||||||
.pipe(takeUntilDestroyed())
|
.pipe(takeUntilDestroyed())
|
||||||
.subscribe(({ cardholderName, number, brand, expMonth, expYear, code }) => {
|
.subscribe(({ cardholderName, number, brand, expMonth, expYear, code }) => {
|
||||||
this.cipherFormContainer.patchCipher((cipher) => {
|
this.cipherFormContainer.patchCipher((cipher) => {
|
||||||
// The input[type="number"] is returning a number, convert it to a string
|
const expirationYear = normalizeExpiryYearFormat(expYear);
|
||||||
// An empty field returns null, avoid casting `"null"` to a string
|
|
||||||
const expirationYear = expYear !== null ? `${expYear}` : null;
|
|
||||||
|
|
||||||
Object.assign(cipher.card, {
|
Object.assign(cipher.card, {
|
||||||
cardholderName,
|
cardholderName,
|
||||||
|
|||||||
Reference in New Issue
Block a user