[PM-10418] Bugfix - Expiration date on cards does not always autofill the correct format (#10705)

* add branching logic for alternative card expiration autofill strategy

* simplify logic and fix some pattern-matching bugs

* add EnableNewCardCombinedExpiryAutofill feature flag

* update default format for card expiry date and update tests

* review reccs
This commit is contained in:
Jonathan Prusik 2024-09-06 11:24:04 -04:00 committed by GitHub
parent 56ededa947
commit 9881c7842b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 544 additions and 184 deletions

View File

@ -90,6 +90,7 @@ export class CreditCardAutoFillConstants {
"data-stripe",
"htmlName",
"htmlID",
"title",
"label-tag",
"placeholder",
"label-left",
@ -299,6 +300,54 @@ export class CreditCardAutoFillConstants {
"cb-type",
];
static readonly CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];
// Note, these are expressions of user-guidance for the expected expiry date format to be used
static readonly CardExpiryDateFormats: CardExpiryDateFormat[] = [
// English
{
Month: "mm",
MonthShort: "m",
Year: "yyyy",
YearShort: "yy",
},
// Danish
{
Month: "mm",
MonthShort: "m",
Year: "åååå",
YearShort: "åå",
},
// German/Dutch
{
Month: "mm",
MonthShort: "m",
Year: "jjjj",
YearShort: "jj",
},
// French/Spanish/Italian
{
Month: "mm",
MonthShort: "m",
Year: "aa",
YearShort: "aa",
},
// Russian
{
Month: "мм",
MonthShort: "м",
Year: "гггг",
YearShort: "гг",
},
// Portuguese
{
Month: "mm",
MonthShort: "m",
Year: "rrrr",
YearShort: "rr",
},
];
// Each index represents a language. These three arrays should all be the same length.
// 0: English, 1: Danish, 2: German/Dutch, 3: French/Spanish/Italian, 4: Russian, 5: Portuguese
static readonly MonthAbbr = ["mm", "mm", "mm", "mm", "мм", "mm"];
@ -306,6 +355,13 @@ export class CreditCardAutoFillConstants {
static readonly YearAbbrLong = ["yyyy", "åååå", "jjjj", "aa", "гггг", "rrrr"];
}
export type CardExpiryDateFormat = {
Month: string;
MonthShort: string;
Year: string;
YearShort: string;
};
export class IdentityAutoFillConstants {
static readonly IdentityAttributes: string[] = [
"autoCompleteType",

View File

@ -2475,10 +2475,10 @@ describe("AutofillService", () => {
options.cipher.card = mock<CardView>();
});
it("returns null if the passed options contains a cipher with no card view", () => {
it("returns null if the passed options contains a cipher with no card view", async () => {
options.cipher.card = undefined;
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2499,7 +2499,7 @@ describe("AutofillService", () => {
untrustedIframe: false,
};
it("returns an unmodified fill script when the field is a `span` field", () => {
it("returns an unmodified fill script when the field is a `span` field", async () => {
const spanField = createAutofillFieldMock({
opid: "span-field",
form: "validFormId",
@ -2510,7 +2510,7 @@ describe("AutofillService", () => {
pageDetails.fields = [spanField];
jest.spyOn(AutofillService, "isExcludedFieldType");
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2522,7 +2522,7 @@ describe("AutofillService", () => {
});
AutoFillConstants.ExcludedAutofillTypes.forEach((excludedType) => {
it(`returns an unmodified fill script when the field has a '${excludedType}' type`, () => {
it(`returns an unmodified fill script when the field has a '${excludedType}' type`, async () => {
const invalidField = createAutofillFieldMock({
opid: `${excludedType}-field`,
form: "validFormId",
@ -2533,7 +2533,7 @@ describe("AutofillService", () => {
pageDetails.fields = [invalidField];
jest.spyOn(AutofillService, "isExcludedFieldType");
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2548,7 +2548,7 @@ describe("AutofillService", () => {
});
});
it("returns an unmodified fill script when the field is not viewable", () => {
it("returns an unmodified fill script when the field is not viewable", async () => {
const notViewableField = createAutofillFieldMock({
opid: "invalid-field",
form: "validFormId",
@ -2561,7 +2561,7 @@ describe("AutofillService", () => {
jest.spyOn(AutofillService, "forCustomFieldsOnly");
jest.spyOn(AutofillService, "isExcludedFieldType");
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2663,8 +2663,8 @@ describe("AutofillService", () => {
jest.spyOn(autofillService as any, "makeScriptActionWithValue");
});
it("returns a fill script containing all of the passed card fields", () => {
const value = autofillService["generateCardFillScript"](
it("returns a fill script containing all of the passed card fields", async () => {
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2745,11 +2745,11 @@ describe("AutofillService", () => {
options.cipher.card.expMonth = "05";
});
it("returns an expiration month parsed from found select options within the field", () => {
it("returns an expiration month parsed from found select options within the field", async () => {
const testValue = "sometestvalue";
expMonthField.selectInfo.options[4] = ["May", testValue];
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2759,12 +2759,12 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]);
});
it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", () => {
it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the end of the list of options", async () => {
const testValue = "sometestvalue";
expMonthField.selectInfo.options[4] = ["May", testValue];
expMonthField.selectInfo.options.push(["", ""]);
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2774,12 +2774,12 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]);
});
it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", () => {
it("returns an expiration month parsed from found select options within the field when the select field has an empty option at the start of the list of options", async () => {
const testValue = "sometestvalue";
expMonthField.selectInfo.options[4] = ["May", testValue];
expMonthField.selectInfo.options.unshift(["", ""]);
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2789,13 +2789,13 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expMonthField.opid, testValue]);
});
it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", () => {
it("returns an expiration month with a zero attached if the field requires two characters, and the vault item has only one character", async () => {
options.cipher.card.expMonth = "5";
expMonthField.selectInfo = null;
expMonthField.placeholder = "mm";
expMonthField.maxLength = 2;
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2830,12 +2830,12 @@ describe("AutofillService", () => {
options.cipher.card.expYear = "2024";
});
it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", () => {
it("returns an expiration year parsed from the select options if an exact match is found for either the select option text or value", async () => {
const someTestValue = "sometestvalue";
expYearField.selectInfo.options[1] = ["2024", someTestValue];
options.cipher.card.expYear = someTestValue;
let value = autofillService["generateCardFillScript"](
let value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2846,7 +2846,7 @@ describe("AutofillService", () => {
expYearField.selectInfo.options[1] = [someTestValue, "2024"];
value = autofillService["generateCardFillScript"](
value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2856,12 +2856,12 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, someTestValue]);
});
it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", () => {
it("returns an expiration year parsed from the select options if the value of an option contains only two characters and the vault item value contains four characters", async () => {
const yearValue = "26";
expYearField.selectInfo.options.push(["The year 2026", yearValue]);
options.cipher.card.expYear = "2026";
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2871,13 +2871,13 @@ describe("AutofillService", () => {
expect(value.script[2]).toStrictEqual(["fill_by_opid", expYearField.opid, yearValue]);
});
it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", () => {
it("returns an expiration year parsed from the select options if the vault of an option is separated by a colon", async () => {
const yearValue = "26";
const colonSeparatedYearValue = `2:0${yearValue}`;
expYearField.selectInfo.options.push(["The year 2026", colonSeparatedYearValue]);
options.cipher.card.expYear = yearValue;
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2891,14 +2891,14 @@ describe("AutofillService", () => {
]);
});
it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", () => {
it("returns an expiration year with `20` prepended to the vault item value if the field to be filled expects a `yyyy` format but the vault item only has two characters", async () => {
const yearValue = "26";
expYearField.selectInfo = null;
expYearField.placeholder = "yyyy";
expYearField.maxLength = 4;
options.cipher.card.expYear = yearValue;
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2912,14 +2912,14 @@ describe("AutofillService", () => {
]);
});
it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", () => {
it("returns an expiration year with only the last two values if the field to be filled expects a `yy` format but the vault item contains four characters", async () => {
const yearValue = "26";
expYearField.selectInfo = null;
expYearField.placeholder = "yy";
expYearField.maxLength = 2;
options.cipher.card.expYear = `20${yearValue}`;
const value = autofillService["generateCardFillScript"](
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2930,11 +2930,26 @@ describe("AutofillService", () => {
});
});
const expectedDateFormats = [
["mm/yyyy", "05/2024"],
["mm/yy", "05/24"],
["yyyy/mm", "2024/05"],
["yy/mm", "24/05"],
["mm-yyyy", "05-2024"],
["mm-yy", "05-24"],
["yyyy-mm", "2024-05"],
["yy-mm", "24-05"],
["yyyymm", "202405"],
["yymm", "2405"],
["mmyyyy", "052024"],
["mmyy", "0524"],
];
describe("given a generic expiration date field", () => {
let expirationDateField: AutofillField;
let expirationDateFieldView: FieldView;
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
expirationDateField = createAutofillFieldMock({
opid: "expirationDate",
form: "validFormId",
@ -2949,23 +2964,11 @@ describe("AutofillService", () => {
options.cipher.card.expYear = "2024";
});
const expectedDateFormats = [
["mm/yyyy", "05/2024"],
["mm/yy", "05/24"],
["yyyy/mm", "2024/05"],
["yy/mm", "24/05"],
["mm-yyyy", "05-2024"],
["mm-yy", "05-24"],
["yyyy-mm", "2024-05"],
["yy-mm", "24-05"],
["yyyymm", "202405"],
["yymm", "2405"],
["mmyyyy", "052024"],
["mmyy", "0524"],
];
expectedDateFormats.forEach((dateFormat, index) => {
it(`returns an expiration date format matching '${dateFormat[0]}'`, () => {
it(`returns an expiration date format matching '${dateFormat[0]}'`, async () => {
expirationDateField.placeholder = dateFormat[0];
// test alternate stored cipher value formats
if (index === 0) {
options.cipher.card.expYear = "24";
}
@ -2973,7 +2976,13 @@ describe("AutofillService", () => {
options.cipher.card.expMonth = "5";
}
const value = autofillService["generateCardFillScript"](
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(false);
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
@ -2984,17 +2993,128 @@ describe("AutofillService", () => {
});
});
it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", () => {
const value = autofillService["generateCardFillScript"](
it("returns an expiration date format matching `yyyy-mm` if no valid format can be identified", async () => {
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(false);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "2024-05"]);
});
});
const extraExpectedDateFormats = [
...expectedDateFormats,
["m yy", "5 24"],
["m yyyy", "5 2024"],
["m-yy", "5-24"],
["m-yyyy", "5-2024"],
["m.yy", "5.24"],
["m.yyyy", "5.2024"],
["m/yy", "5/24"],
["m/yyyy", "5/2024"],
["mm åååå", "05 2024"],
["mm yy", "05 24"],
["mm yyyy", "05 2024"],
["mm.yy", "05.24"],
["mm.yyyy", "05.2024"],
["myy", "524"],
["myyyy", "52024"],
["yy m", "24 5"],
["yy mm", "24 05"],
["yy mm", "24 05"],
["yy-m", "24-5"],
["yy.m", "24.5"],
["yy.mm", "24.05"],
["yy/m", "24/5"],
["yym", "245"],
["yyyy m", "2024 5"],
["yyyy mm", "2024 05"],
["yyyy-m", "2024-5"],
["yyyy.m", "2024.5"],
["yyyy.mm", "2024.05"],
["yyyy/m", "2024/5"],
["yyyym", "20245"],
["мм гг", "05 24"],
];
describe("given a generic expiration date field with the `enable-new-card-combined-expiry-autofill` feature-flag enabled", () => {
let expirationDateField: AutofillField;
let expirationDateFieldView: FieldView;
beforeEach(() => {
configService.getFeatureFlag.mockResolvedValue(true);
expirationDateField = createAutofillFieldMock({
opid: "expirationDate",
form: "validFormId",
elementNumber: 3,
htmlName: "expiration-date",
});
filledFields["exp-field"] = expirationDateField;
expirationDateFieldView = mock<FieldView>({ name: "exp" });
pageDetails.fields = [expirationDateField];
options.cipher.fields = [expirationDateFieldView];
options.cipher.card.expMonth = "05";
options.cipher.card.expYear = "2024";
});
afterEach(() => {
configService.getFeatureFlag.mockResolvedValue(false);
});
extraExpectedDateFormats.forEach((dateFormat, index) => {
it(`feature-flagged logic returns an expiration date format matching '${dateFormat[0]}'`, async () => {
expirationDateField.placeholder = dateFormat[0];
// test alternate stored cipher value formats
if (index === 0) {
options.cipher.card.expYear = "24";
}
if (index === 1) {
options.cipher.card.expMonth = "05";
}
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(true);
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", dateFormat[1]]);
});
});
it("feature-flagged logic returns an expiration date format matching `mm/yy` if no valid format can be identified", async () => {
const value = await autofillService["generateCardFillScript"](
fillScript,
pageDetails,
filledFields,
options,
);
const enableNewCardCombinedExpiryAutofill = await configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
expect(enableNewCardCombinedExpiryAutofill).toEqual(true);
expect(value.script[2]).toStrictEqual(["fill_by_opid", "expirationDate", "05/24"]);
});
});
});
describe("inUntrustedIframe", () => {

View File

@ -26,6 +26,7 @@ import { CipherService } from "@bitwarden/common/vault/abstractions/cipher.servi
import { TotpService } from "@bitwarden/common/vault/abstractions/totp.service";
import { FieldType, CipherType } from "@bitwarden/common/vault/enums";
import { CipherRepromptType } from "@bitwarden/common/vault/enums/cipher-reprompt-type";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
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";
@ -50,6 +51,7 @@ import {
} from "./abstractions/autofill.service";
import {
AutoFillConstants,
CardExpiryDateFormat,
CreditCardAutoFillConstants,
IdentityAutoFillConstants,
} from "./autofill-constants";
@ -721,7 +723,12 @@ export default class AutofillService implements AutofillServiceInterface {
);
break;
case CipherType.Card:
fillScript = this.generateCardFillScript(fillScript, pageDetails, filledFields, options);
fillScript = await this.generateCardFillScript(
fillScript,
pageDetails,
filledFields,
options,
);
break;
case CipherType.Identity:
fillScript = await this.generateIdentityFillScript(
@ -937,12 +944,12 @@ export default class AutofillService implements AutofillServiceInterface {
* @returns {AutofillScript|null}
* @private
*/
private generateCardFillScript(
private async generateCardFillScript(
fillScript: AutofillScript,
pageDetails: AutofillPageDetails,
filledFields: { [id: string]: AutofillField },
options: GenerateFillScriptOptions,
): AutofillScript | null {
): Promise<AutofillScript | null> {
if (!options.cipher.card) {
return null;
}
@ -1027,6 +1034,7 @@ export default class AutofillService implements AutofillServiceInterface {
this.makeScriptAction(fillScript, card, fillFields, filledFields, "code");
this.makeScriptAction(fillScript, card, fillFields, filledFields, "brand");
// There is an expiration month field and the cipher has an expiration month value
if (fillFields.expMonth && AutofillService.hasValue(card.expMonth)) {
let expMonth: string = card.expMonth;
@ -1065,6 +1073,7 @@ export default class AutofillService implements AutofillServiceInterface {
AutofillService.fillByOpid(fillScript, fillFields.expMonth, expMonth);
}
// There is an expiration year field and the cipher has an expiration year value
if (fillFields.expYear && AutofillService.hasValue(card.expYear)) {
let expYear: string = card.expYear;
if (fillFields.expYear.selectInfo && fillFields.expYear.selectInfo.options) {
@ -1111,142 +1120,174 @@ export default class AutofillService implements AutofillServiceInterface {
AutofillService.fillByOpid(fillScript, fillFields.expYear, expYear);
}
// There is a single expiry date field (combined values) and the cipher has both expiration month and year
if (
fillFields.exp &&
AutofillService.hasValue(card.expMonth) &&
AutofillService.hasValue(card.expYear)
) {
const fullMonth = ("0" + card.expMonth).slice(-2);
let combinedExpiryFillValue = null;
let fullYear: string = card.expYear;
let partYear: string = null;
if (fullYear.length === 2) {
partYear = fullYear;
fullYear = normalizeExpiryYearFormat(fullYear);
} else if (fullYear.length === 4) {
partYear = fullYear.substr(2, 2);
}
const enableNewCardCombinedExpiryAutofill = await this.configService.getFeatureFlag(
FeatureFlag.EnableNewCardCombinedExpiryAutofill,
);
let exp: string = null;
for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) {
if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"/" +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
exp = fullMonth + "/" + fullYear;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"/" +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
exp = fullMonth + "/" + partYear;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
"/" +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
exp = fullYear + "/" + fullMonth;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
"/" +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
exp = partYear + "/" + fullMonth;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"-" +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
exp = fullMonth + "-" + fullYear;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"-" +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
exp = fullMonth + "-" + partYear;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
"-" +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
exp = fullYear + "-" + fullMonth;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
"-" +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
exp = partYear + "-" + fullMonth;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] + CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
exp = fullYear + fullMonth;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] + CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
exp = partYear + fullMonth;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
exp = fullMonth + fullYear;
} else if (
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] + CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
exp = fullMonth + partYear;
if (enableNewCardCombinedExpiryAutofill) {
combinedExpiryFillValue = this.generateCombinedExpiryValue(card, fillFields.exp);
} else {
const fullMonth = ("0" + card.expMonth).slice(-2);
let fullYear: string = card.expYear;
let partYear: string = null;
if (fullYear.length === 2) {
partYear = fullYear;
fullYear = normalizeExpiryYearFormat(fullYear);
} else if (fullYear.length === 4) {
partYear = fullYear.substr(2, 2);
}
if (exp != null) {
break;
for (let i = 0; i < CreditCardAutoFillConstants.MonthAbbr.length; i++) {
if (
// mm/yyyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"/" +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
combinedExpiryFillValue = fullMonth + "/" + fullYear;
} else if (
// mm/yy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"/" +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
combinedExpiryFillValue = fullMonth + "/" + partYear;
} else if (
// yyyy/mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
"/" +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
combinedExpiryFillValue = fullYear + "/" + fullMonth;
} else if (
// yy/mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
"/" +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
combinedExpiryFillValue = partYear + "/" + fullMonth;
} else if (
// mm-yyyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"-" +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
combinedExpiryFillValue = fullMonth + "-" + fullYear;
} else if (
// mm-yy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
"-" +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
combinedExpiryFillValue = fullMonth + "-" + partYear;
} else if (
// yyyy-mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
"-" +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
combinedExpiryFillValue = fullYear + "-" + fullMonth;
} else if (
// yy-mm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
"-" +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
combinedExpiryFillValue = partYear + "-" + fullMonth;
} else if (
// yyyymm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrLong[i] +
CreditCardAutoFillConstants.MonthAbbr[i],
)
) {
combinedExpiryFillValue = fullYear + fullMonth;
} else if (
// yymm
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.YearAbbrShort[i] +
CreditCardAutoFillConstants.MonthAbbr[i],
) &&
partYear != null
) {
combinedExpiryFillValue = partYear + fullMonth;
} else if (
// mmyyyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
CreditCardAutoFillConstants.YearAbbrLong[i],
)
) {
combinedExpiryFillValue = fullMonth + fullYear;
} else if (
// mmyy
this.fieldAttrsContain(
fillFields.exp,
CreditCardAutoFillConstants.MonthAbbr[i] +
CreditCardAutoFillConstants.YearAbbrShort[i],
) &&
partYear != null
) {
combinedExpiryFillValue = fullMonth + partYear;
}
if (combinedExpiryFillValue != null) {
break;
}
}
// If none of the previous cases applied, set as default
if (combinedExpiryFillValue == null) {
combinedExpiryFillValue = fullYear + "-" + fullMonth;
}
}
if (exp == null) {
exp = fullYear + "-" + fullMonth;
}
this.makeScriptActionWithValue(fillScript, exp, fillFields.exp, filledFields);
this.makeScriptActionWithValue(
fillScript,
combinedExpiryFillValue,
fillFields.exp,
filledFields,
);
}
return fillScript;
@ -1287,28 +1328,169 @@ export default class AutofillService implements AutofillServiceInterface {
* Used when handling autofill on credit card fields. Determines whether
* the field has an attribute that matches the given value.
* @param {AutofillField} field
* @param {string} containsVal
* @param {string} containsValue
* @returns {boolean}
* @private
*/
private fieldAttrsContain(field: AutofillField, containsVal: string): boolean {
private fieldAttrsContain(field: AutofillField, containsValue: string): boolean {
if (!field) {
return false;
}
let doesContain = false;
CreditCardAutoFillConstants.CardAttributesExtended.forEach((attr) => {
// eslint-disable-next-line
if (doesContain || !field.hasOwnProperty(attr) || !field[attr]) {
let doesContainValue = false;
CreditCardAutoFillConstants.CardAttributesExtended.forEach((attributeName) => {
// eslint-disable-next-line no-prototype-builtins
if (doesContainValue || !field[attributeName]) {
return;
}
let val = field[attr];
val = val.replace(/ /g, "").toLowerCase();
doesContain = val.indexOf(containsVal) > -1;
let fieldValue = field[attributeName];
fieldValue = fieldValue.replace(/ /g, "").toLowerCase();
doesContainValue = fieldValue.indexOf(containsValue) > -1;
});
return doesContain;
return doesContainValue;
}
/**
* Returns a string value representation of the combined card expiration month and year values
* in a format matching discovered guidance within the field attributes (typically provided for users).
*
* @param {CardView} cardCipher
* @param {AutofillField} field
*/
private generateCombinedExpiryValue(cardCipher: CardView, field: AutofillField): string {
/*
Some expectations of the passed stored card cipher view:
- At the time of writing, the stored card expiry year value (`expYear`)
can be any arbitrary string (no format validation). We may attempt some format
normalization here, but expect the user to have entered a string of integers
with a length of 2 or 4
- the `expiration` property cannot be used for autofill as it is an opinionated
format
- `expMonth` a stringified integer stored with no zero-padding and is not
zero-indexed (e.g. January is "1", not "01" or 0)
*/
// Expiry format options
let useMonthPadding = true;
let useYearFull = false;
let delimiter = "/";
let orderByYear = false;
// Because users are allowed to store truncated years, we need to make assumptions
// about the full year format when called for
const currentCentury = `${new Date().getFullYear()}`.slice(0, 2);
// Note, we construct the output rather than doing string replacement against the
// format guidance pattern to avoid edge cases that would output invalid values
const [
// The guidance parsed from the field properties regarding expiry format
expectedExpiryDateFormat,
// The (localized) date pattern set that was used to parse the expiry format guidance
expiryDateFormatPatterns,
] = this.getExpectedExpiryDateFormat(field);
if (expectedExpiryDateFormat) {
const { Month, MonthShort, Year } = expiryDateFormatPatterns;
const expiryDateDelimitersPattern =
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
// assign the delimiter from the expected format string
delimiter =
expectedExpiryDateFormat.match(new RegExp(`[${expiryDateDelimitersPattern}]`, "g"))?.[0] ||
"";
// check if the expected format starts with a month form
// order matters here; check long form first, since short form will match against long
if (expectedExpiryDateFormat.indexOf(Month + delimiter) === 0) {
useMonthPadding = true;
orderByYear = false;
} else if (expectedExpiryDateFormat.indexOf(MonthShort + delimiter) === 0) {
useMonthPadding = false;
orderByYear = false;
} else {
orderByYear = true;
// short form can match against long form, but long won't match against short
const containsLongMonthPattern = new RegExp(`${Month}`, "i");
useMonthPadding = containsLongMonthPattern.test(expectedExpiryDateFormat);
}
const containsLongYearPattern = new RegExp(`${Year}`, "i");
useYearFull = containsLongYearPattern.test(expectedExpiryDateFormat);
}
const month = useMonthPadding
? // Ensure zero-padding
("0" + cardCipher.expMonth).slice(-2)
: // Handle zero-padded stored month values, even though they are not _expected_ to be as such
cardCipher.expMonth.replaceAll("0", "");
// Note: assumes the user entered an `expYear` value with a length of either 2 or 4
const year = (currentCentury + cardCipher.expYear).slice(useYearFull ? -4 : -2);
const combinedExpiryFillValue = (orderByYear ? [year, month] : [month, year]).join(delimiter);
return combinedExpiryFillValue;
}
/**
* Returns a string value representation of discovered guidance for a combined month and year expiration value from the field attributes
*
* @param {AutofillField} field
*/
private getExpectedExpiryDateFormat(
field: AutofillField,
): [string | null, CardExpiryDateFormat | null] {
let expectedDateFormat = null;
let dateFormatPatterns = null;
const expiryDateDelimitersPattern =
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => {
dateFormatPatterns = dateFormat;
const { Month, MonthShort, YearShort, Year } = dateFormat;
// Non-exhaustive coverage of field guidances. Some uncovered edge cases: ". " delimiter, space-delimited delimiters ("mm / yyyy").
// We should consider if added whitespace is for improved readability of user-guidance or actually desired in the filled value.
// e.g. "/((mm|m)[\/\-\.\ ]{0,1}(yyyy|yy))|((yyyy|yy)[\/\-\.\ ]{0,1}(mm|m))/gi"
const dateFormatPattern = new RegExp(
`((${Month}|${MonthShort})[${expiryDateDelimitersPattern}]{0,1}(${Year}|${YearShort}))|((${Year}|${YearShort})[${expiryDateDelimitersPattern}]{0,1}(${Month}|${MonthShort}))`,
"gi",
);
return CreditCardAutoFillConstants.CardAttributesExtended.find((attributeName) => {
const fieldAttributeValue = field[attributeName];
const fieldAttributeMatch = fieldAttributeValue?.match(dateFormatPattern);
// break find as soon as a match is found
if (fieldAttributeMatch?.length) {
expectedDateFormat = fieldAttributeMatch[0];
// remove any irrelevant characters
const irrelevantExpiryCharactersPattern = new RegExp(
// "or digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
`[^\\w${expiryDateDelimitersPattern}]|[\\d]`,
"gi",
);
expectedDateFormat.replaceAll(irrelevantExpiryCharactersPattern, "");
return true;
}
return false;
});
});
return [expectedDateFormat, dateFormatPatterns];
}
/**

View File

@ -30,6 +30,7 @@ export enum FeatureFlag {
UnauthenticatedExtensionUIRefresh = "unauth-ui-refresh",
EnableUpgradePasswordManagerSub = "AC-2708-upgrade-password-manager-sub",
GenerateIdentityFillScriptRefactor = "generate-identity-fill-script-refactor",
EnableNewCardCombinedExpiryAutofill = "enable-new-card-combined-expiry-autofill",
DelayFido2PageScriptInitWithinMv2 = "delay-fido2-page-script-init-within-mv2",
AccountDeprovisioning = "pm-10308-account-deprovisioning",
NotificationBarAddLoginImprovements = "notification-bar-add-login-improvements",
@ -76,6 +77,7 @@ export const DefaultFeatureFlagValue = {
[FeatureFlag.UnauthenticatedExtensionUIRefresh]: FALSE,
[FeatureFlag.EnableUpgradePasswordManagerSub]: FALSE,
[FeatureFlag.GenerateIdentityFillScriptRefactor]: FALSE,
[FeatureFlag.EnableNewCardCombinedExpiryAutofill]: FALSE,
[FeatureFlag.DelayFido2PageScriptInitWithinMv2]: FALSE,
[FeatureFlag.StorageReseedRefactor]: FALSE,
[FeatureFlag.AccountDeprovisioning]: FALSE,