Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weโ€™ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PM-11588] Bugfix - parse user input value for combined expiry date when creating/adding a card cipher #11103

Merged
merged 10 commits into from
Sep 24, 2024
18 changes: 16 additions & 2 deletions apps/browser/src/autofill/background/overlay.background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,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 { parseYearMonthExpiry } from "@bitwarden/common/vault/utils";

import { openUnlockPopout } from "../../auth/popup/utils/auth-popout-window";
import { BrowserApi } from "../../platform/browser/browser-api";
Expand Down Expand Up @@ -1898,11 +1899,24 @@
const cardView = new CardView();
cardView.cardholderName = card.cardholderName || "";
cardView.number = card.number || "";
cardView.expMonth = card.expirationMonth || "";
cardView.expYear = card.expirationYear || "";
cardView.code = card.cvv || "";
cardView.brand = card.number ? CardView.getCardBrandByPatterns(card.number) : "";

// If there's a combined expiration date value and no individual month or year values,
// try to parse them from the combined value
if (
card.expirationDate &&
(card.expirationMonth == null || card.expirationMonth === "") &&
(card.expirationYear == null || card.expirationYear === "")
cagonzalezcs marked this conversation as resolved.
Show resolved Hide resolved
) {
const [parsedYear, parsedMonth] = parseYearMonthExpiry(card.expirationDate);

Check warning on line 1912 in apps/browser/src/autofill/background/overlay.background.ts

View check run for this annotation

Codecov / codecov/patch

apps/browser/src/autofill/background/overlay.background.ts#L1912

Added line #L1912 was not covered by tests
cardView.expMonth = parsedMonth || "";
cardView.expYear = parsedYear || "";
} else {
cardView.expMonth = card.expirationMonth || "";
cardView.expYear = card.expirationYear || "";
}

const cipherView = new CipherView();
cipherView.name = "";
cipherView.folderId = null;
Expand Down
2 changes: 0 additions & 2 deletions apps/browser/src/autofill/services/autofill-constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,8 +300,6 @@ 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
Expand Down
13 changes: 8 additions & 5 deletions apps/browser/src/autofill/services/autofill.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,10 @@ import { AccountInfo, AccountService } from "@bitwarden/common/auth/abstractions
import { AuthService } from "@bitwarden/common/auth/abstractions/auth.service";
import { UserVerificationService } from "@bitwarden/common/auth/abstractions/user-verification/user-verification.service.abstraction";
import { AuthenticationStatus } from "@bitwarden/common/auth/enums/authentication-status";
import { AutofillOverlayVisibility } from "@bitwarden/common/autofill/constants";
import {
AutofillOverlayVisibility,
CardExpiryDateDelimiters,
} from "@bitwarden/common/autofill/constants";
import { AutofillSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/autofill-settings.service";
import { DomainSettingsService } from "@bitwarden/common/autofill/services/domain-settings.service";
import { UserNotificationSettingsServiceAbstraction } from "@bitwarden/common/autofill/services/user-notification-settings.service";
Expand Down Expand Up @@ -1397,8 +1400,7 @@ export default class AutofillService implements AutofillServiceInterface {
if (expectedExpiryDateFormat) {
const { Month, MonthShort, Year } = expiryDateFormatPatterns;

const expiryDateDelimitersPattern =
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\");

// assign the delimiter from the expected format string
delimiter =
Expand Down Expand Up @@ -1450,8 +1452,7 @@ export default class AutofillService implements AutofillServiceInterface {
let expectedDateFormat = null;
let dateFormatPatterns = null;

const expiryDateDelimitersPattern =
"\\" + CreditCardAutoFillConstants.CardExpiryDateDelimiters.join("\\");
const expiryDateDelimitersPattern = "\\" + CardExpiryDateDelimiters.join("\\");

CreditCardAutoFillConstants.CardExpiryDateFormats.find((dateFormat) => {
dateFormatPatterns = dateFormat;
Expand Down Expand Up @@ -1489,6 +1490,8 @@ export default class AutofillService implements AutofillServiceInterface {
return false;
});
});
// @TODO if expectedDateFormat is still null, and there is a `pattern` attribute, cycle
// through generated formatted values, checking against the provided regex pattern
cagonzalezcs marked this conversation as resolved.
Show resolved Hide resolved

return [expectedDateFormat, dateFormatPatterns];
}
Expand Down
2 changes: 2 additions & 0 deletions libs/common/src/autofill/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,5 @@ export type ExtensionCommandType = (typeof ExtensionCommand)[keyof typeof Extens
export const CLEAR_NOTIFICATION_LOGIN_DATA_DURATION = 60 * 1000; // 1 minute

export const MAX_DEEP_QUERY_RECURSION_DEPTH = 4;

export * from "./match-patterns";
26 changes: 26 additions & 0 deletions libs/common/src/autofill/constants/match-patterns.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export const CardExpiryDateDelimiters: string[] = ["/", "-", ".", " "];

// `CardExpiryDateDelimiters` is not intended solely for regex consumption,
// so we need to format it here
cagonzalezcs marked this conversation as resolved.
Show resolved Hide resolved
export const ExpiryDateDelimitersPattern =
"\\" +
CardExpiryDateDelimiters.join("\\")
// replace space character with the regex whitespace character class
.replace(" ", "s");

export const MonthPattern = "(([1]{1}[0-2]{1})|(0?[1-9]{1}))";

// Because we're dealing with expiry dates, we assume the year will be in current or next century (as of 2024)
export const ExpiryFullYearPattern = "2[0-1]{1}\\d{2}";

export const DelimiterPatternExpression = new RegExp(`[${ExpiryDateDelimitersPattern}]`, "g");

export const IrrelevantExpiryCharactersPatternExpression = new RegExp(
// "nor digits" to ensure numbers are removed from guidance pattern, which aren't covered by ^\w
`[^\\d${ExpiryDateDelimitersPattern}]`,
"g",
);

export const MonthPatternExpression = new RegExp(`^${MonthPattern}$`);

export const ExpiryFullYearPatternExpression = new RegExp(`^${ExpiryFullYearPattern}$`);
164 changes: 163 additions & 1 deletion libs/common/src/vault/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
import { normalizeExpiryYearFormat, isCardExpired } from "@bitwarden/common/vault/utils";
import {
normalizeExpiryYearFormat,
isCardExpired,
parseYearMonthExpiry,
} from "@bitwarden/common/vault/utils";

function getExpiryYearValueFormats(currentCentury: string) {
return [
Expand Down Expand Up @@ -87,6 +91,7 @@ function getCardExpiryDateValues() {
[undefined, undefined, false], // no month, no year, invalid values
["", "", false], // no month, no year, invalid values
["12", "agdredg42grg35grrr. ea3534@#^145345ag$%^ -_#$rdg ", false], // invalid values
["0", `${currentYear}`, true], // invalid month
["0", `${currentYear - 1}`, true], // invalid 0 month
["00", `${currentYear + 1}`, false], // invalid 0 month
[`${currentMonth}`, "0000", true], // current month, in the year 2000
Expand Down Expand Up @@ -120,3 +125,160 @@ describe("isCardExpired", () => {
},
);
});

const combinedDateTestValues = [
" 2024 / 05 ",
"05 2024",
"05 2024", // Tab whitespace character
"05โ€2024", // Em Quad
"05โ€ƒ2024", // Em Space
"05โ€€2024", // En Quad
"05โ€‚2024", // En Space
"05โ€‡2024", // Figure Space
"05โ€…2024", // Four-Per-Em Space
"05โ€Š2024", // Hair Space
"05ใ€€2024", // Ideographic Space
"05โŸ2024", // Medium Mathematical Space
"05 2024", // No-Break Space
"05แš€2024", // ogham space mark
"05โ€ˆ2024", // Punctuation Space
"05โ€†2024", // Six-Per-Em Space
"05โ€‰2024", // Thin Space
"05โ€„2024", // Three-Per-Em Space
"05 24",
"05-2024",
"05-24",
"05.2024",
"05.24",
"05/2024",
"05/24",
"052024",
"0524",
"2024 05",
"2024 5",
"2024-05",
"2024-5",
"2024.05",
"2024.5",
"2024/05",
"2024/5",
"202405",
"20245",
"24 05",
"24 5",
"24-05",
"24-5",
"24.05",
"24.5",
"24/05",
"24/5",
"2405",
"5 2024",
"5 24",
"5-2024",
"5-24",
"5.2024",
"5.24",
"5/2024",
"5/24",
"52024",
];
const expectedParsedValue = ["2024", "5"];
describe("parseYearMonthExpiry", () => {
it('returns "null" expiration year and month values when a value of "" is passed', () => {
expect(parseYearMonthExpiry("")).toStrictEqual([null, null]);
});

it('returns "null" expiration year and month values when a value of "/" is passed', () => {
expect(parseYearMonthExpiry("/")).toStrictEqual([null, null]);
});

combinedDateTestValues.forEach((combinedDate) => {
it(`returns an expiration year value of "${expectedParsedValue[0]}" and month value of "${expectedParsedValue[1]}" when a value of "${combinedDate}" is passed`, () => {
expect(parseYearMonthExpiry(combinedDate)).toStrictEqual(expectedParsedValue);
});
});

it('returns an expiration year value of "2002" and month value of "2" when a value of "022" is passed', () => {
expect(parseYearMonthExpiry("022")).toStrictEqual(["2002", "2"]);
});

it('returns an expiration year value of "2002" and month value of "2" when a value of "202" is passed', () => {
expect(parseYearMonthExpiry("202")).toStrictEqual(["2002", "2"]);
});

it('returns an expiration year value of "2002" and month value of "1" when a value of "1/2/3/4" is passed', () => {
expect(parseYearMonthExpiry("1/2/3/4")).toStrictEqual(["2002", "1"]);
});

it('returns valid expiration year and month values when a value of "198" is passed', () => {
// This static value will cause the test to fail in 2098
const testValue = "198";
const parsedValue = parseYearMonthExpiry(testValue);

expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);

expect(parsedValue).toStrictEqual(["2098", "1"]);
});

// Ambiguous input cases: we use try/catch for these cases as a workaround to accept either
// outcome (both are valid interpretations) in the event of any future code changes.
describe("ambiguous input cases", () => {
it('returns valid expiration year and month values when a value of "111" is passed', () => {
const testValue = "111";
const parsedValue = parseYearMonthExpiry(testValue);

expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);

try {
expect(parsedValue).toStrictEqual(["2011", "1"]);
} catch {
expect(parsedValue).toStrictEqual(["2001", "11"]);
}
});

it('returns valid expiration year and month values when a value of "212" is passed', () => {
const testValue = "212";
const parsedValue = parseYearMonthExpiry(testValue);

expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);

try {
expect(parsedValue).toStrictEqual(["2012", "2"]);
} catch {
expect(parsedValue).toStrictEqual(["2021", "2"]);
}
});

it('returns valid expiration year and month values when a value of "245" is passed', () => {
const testValue = "245";
const parsedValue = parseYearMonthExpiry(testValue);

expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);

try {
expect(parsedValue).toStrictEqual(["2045", "2"]);
} catch {
expect(parsedValue).toStrictEqual(["2024", "5"]);
}
});

it('returns valid expiration year and month values when a value of "524" is passed', () => {
const testValue = "524";
const parsedValue = parseYearMonthExpiry(testValue);

expect(parsedValue[0]).toHaveLength(4);
expect(parsedValue[1]).toMatch(/^[\d]{1,2}$/);

try {
expect(parsedValue).toStrictEqual(["2024", "5"]);
} catch {
expect(parsedValue).toStrictEqual(["2052", "4"]);
}
});
});
});
Loading
Loading