1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-11 13:53:34 +00:00
Files
browser/libs/common/src/autofill/utils.ts
Jonathan Prusik 966e8d3fb8 [PM-16667] Followup clarifying work (#12665)
* clean up readability

* fix ts-strict violations

* fix consistency with uncertain cases in isCardExpired
2025-01-07 13:48:18 -05:00

331 lines
11 KiB
TypeScript

import {
DelimiterPatternExpression,
ExpiryFullYearPattern,
ExpiryFullYearPatternExpression,
IrrelevantExpiryCharactersPatternExpression,
MonthPatternExpression,
} from "@bitwarden/common/autofill/constants";
import { CardView } from "@bitwarden/common/vault/models/view/card.view";
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
*
* @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 || (expirationYear && /^[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;
}
/**
* Takes a cipher card view and returns "true" if the month and year affirmativey indicate
* the card is expired. Uncertain cases return "false".
*
* @param {CardView} cipherCard
* @return {*} {boolean}
*/
export function isCardExpired(cipherCard: CardView): boolean {
if (cipherCard) {
const { expMonth = null, expYear = null } = cipherCard;
if (!expYear) {
return false;
}
const now = new Date();
const normalizedYear = normalizeExpiryYearFormat(expYear);
const parsedYear = normalizedYear ? parseInt(normalizedYear, 10) : NaN;
const expiryYearIsBeforeCurrentYear = parsedYear < now.getFullYear();
const expiryYearIsAfterCurrentYear = parsedYear > now.getFullYear();
// If the expiry year is before the current year, skip checking the month, since it must be expired
if (normalizedYear && expiryYearIsBeforeCurrentYear) {
return true;
}
// If the expiry year is after the current year, skip checking the month, since it cannot be expired
if (normalizedYear && expiryYearIsAfterCurrentYear) {
return false;
}
if (normalizedYear && expMonth) {
const parsedMonthInteger = parseInt(expMonth, 10);
const parsedMonthIsValid = parsedMonthInteger && !isNaN(parsedMonthInteger);
// If the parsed month value is 0, we don't know when the expiry passes this year, so do not treat it as expired
if (!parsedMonthIsValid) {
return false;
}
// `Date` months are zero-indexed
const parsedMonth = parsedMonthInteger - 1;
// First day of the next month
const cardExpiry = new Date(parsedYear, parsedMonth + 1, 1);
return cardExpiry <= now;
}
}
return false;
}
/**
* Attempt to split a string into date segments on the basis of expected formats and delimiter symbols.
*
* @param {string} combinedExpiryValue
* @return {*} {string[]}
*/
function splitCombinedDateValues(combinedExpiryValue: string): string[] {
let sanitizedValue = combinedExpiryValue
.replace(IrrelevantExpiryCharactersPatternExpression, "")
.trim();
// Do this after initial value replace to avoid identifying leading whitespace as delimiter
const parsedDelimiter = sanitizedValue.match(DelimiterPatternExpression)?.[0] || null;
let dateParts = [sanitizedValue];
if (parsedDelimiter?.length) {
// If the parsed delimiter is a whitespace character, assign 's' (character class) instead
const delimiterPattern = /\s/.test(parsedDelimiter) ? "\\s" : "\\" + parsedDelimiter;
sanitizedValue = sanitizedValue
// Remove all other delimiter characters not identified as the delimiter
.replace(new RegExp(`[^\\d${delimiterPattern}]`, "g"), "")
// Also de-dupe the delimiter character
.replace(new RegExp(`[${delimiterPattern}]{2,}`, "g"), parsedDelimiter);
dateParts = sanitizedValue.split(parsedDelimiter);
}
return (
dateParts
// remove values that have no length
.filter((splitValue) => splitValue?.length)
);
}
/**
* Given an array of split card expiry date parts,
* returns an array of those values ordered by year then month
*
* @param {string[]} splitDateInput
* @return {*} {([string | null, string | null])}
*/
function parseDelimitedYearMonthExpiry([firstPart, secondPart]: string[]): [string, string] {
// Conditionals here are structured to avoid unnecessary evaluations and are ordered
// from more authoritative checks to checks yielding increasingly inferred conclusions
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (ExpiryFullYearPatternExpression.test(firstPart)) {
return [firstPart, secondPart];
}
// If a 4-digit value is found (when there are multiple parts), it can't be month
if (ExpiryFullYearPatternExpression.test(secondPart)) {
return [secondPart, firstPart];
}
// If it's a two digit value that doesn't match against month pattern, assume it's a year
if (/\d{2}/.test(firstPart) && !MonthPatternExpression.test(firstPart)) {
return [firstPart, secondPart];
}
// If it's a two digit value that doesn't match against month pattern, assume it's a year
if (/\d{2}/.test(secondPart) && !MonthPatternExpression.test(secondPart)) {
return [secondPart, firstPart];
}
// Values are too ambiguous (e.g. "12/09"). For the most part,
// a month-looking value likely is, at the time of writing (year 2024).
let parsedYear = firstPart;
let parsedMonth = secondPart;
if (MonthPatternExpression.test(firstPart)) {
parsedYear = secondPart;
parsedMonth = firstPart;
}
return [parsedYear, parsedMonth];
}
/**
* Given a single string of integers, attempts to identify card expiry date portions within
* and return values ordered by year then month
*
* @param {string} dateInput
* @return {*} {([string | null, string | null])}
*/
function parseNonDelimitedYearMonthExpiry(dateInput: string): [string | null, string | null] {
if (dateInput.length > 4) {
// e.g.
// "052024"
// "202405"
// "20245"
// "52024"
// If the value is over 5-characters long, it likely has a full year format in it
const [parsedYear, parsedMonth] = dateInput
.split(new RegExp(`(?=${ExpiryFullYearPattern})|(?<=${ExpiryFullYearPattern})`, "g"))
.sort((current: string, next: string) => (current.length > next.length ? -1 : 1));
return [parsedYear, parsedMonth];
}
if (dateInput.length === 4) {
// e.g.
// "0524"
// "2405"
// If the `sanitizedFirstPart` value is a length of 4, it must be split in half, since
// neither a year or month will be represented with three characters
const splitFirstPartFirstHalf = dateInput.slice(0, 2);
const splitFirstPartSecondHalf = dateInput.slice(-2);
let parsedYear = splitFirstPartSecondHalf;
let parsedMonth = splitFirstPartFirstHalf;
// If the first part doesn't match a month pattern, assume it's a year
if (!MonthPatternExpression.test(splitFirstPartFirstHalf)) {
parsedYear = splitFirstPartFirstHalf;
parsedMonth = splitFirstPartSecondHalf;
}
return [parsedYear, parsedMonth];
}
// e.g.
// "245"
// "202"
// "212"
// "022"
// "111"
// A valid year representation here must be two characters so try to find it first.
let parsedYear = null;
let parsedMonth = null;
// Split if there is a digit with a leading zero
const splitFirstPartOnLeadingZero = dateInput.split(/(?<=0[1-9]{1})|(?=0[1-9]{1})/);
// Assume a leading zero indicates a month in ambiguous cases (e.g. "202"), since we're
// dealing with expiry dates and the next two-digit year with a leading zero will be 2100
if (splitFirstPartOnLeadingZero.length > 1) {
parsedYear = splitFirstPartOnLeadingZero[0];
parsedMonth = splitFirstPartOnLeadingZero[1];
if (splitFirstPartOnLeadingZero[0].startsWith("0")) {
parsedMonth = splitFirstPartOnLeadingZero[0];
parsedYear = splitFirstPartOnLeadingZero[1];
}
} else {
// Here, a year has to be two-digits, and a month can't be more than one, so assume the first two digits that are greater than the current year is the year representation.
parsedYear = dateInput.slice(0, 2);
parsedMonth = dateInput.slice(-1);
const currentYear = new Date().getFullYear();
const normalizedYearFormat = normalizeExpiryYearFormat(parsedYear);
const normalizedParsedYear = normalizedYearFormat && parseInt(normalizedYearFormat, 10);
const normalizedExpiryYearFormat = normalizeExpiryYearFormat(dateInput.slice(-2));
const normalizedParsedYearAlternative =
normalizedExpiryYearFormat && parseInt(normalizedExpiryYearFormat, 10);
if (
normalizedParsedYear &&
normalizedParsedYear < currentYear &&
normalizedParsedYearAlternative &&
normalizedParsedYearAlternative >= currentYear
) {
parsedYear = dateInput.slice(-2);
parsedMonth = dateInput.slice(0, 1);
}
}
return [parsedYear, parsedMonth];
}
/**
* Attempt to parse year and month parts of a combined expiry date value.
*
* @param {string} combinedExpiryValue
* @return {*} {([string | null, string | null])}
*/
export function parseYearMonthExpiry(combinedExpiryValue: string): [Year | null, string | null] {
let parsedYear = null;
let parsedMonth = null;
const dateParts = splitCombinedDateValues(combinedExpiryValue);
if (dateParts.length < 1) {
return [null, null];
}
const sanitizedFirstPart =
dateParts[0]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
const sanitizedSecondPart =
dateParts[1]?.replace(IrrelevantExpiryCharactersPatternExpression, "") || "";
// If there is only one date part, no delimiter was found in the passed value
if (dateParts.length === 1) {
const [parsedNonDelimitedYear, parsedNonDelimitedMonth] =
parseNonDelimitedYearMonthExpiry(sanitizedFirstPart);
parsedYear = parsedNonDelimitedYear;
parsedMonth = parsedNonDelimitedMonth;
}
// There are multiple date parts
else {
const [parsedDelimitedYear, parsedDelimitedMonth] = parseDelimitedYearMonthExpiry([
sanitizedFirstPart,
sanitizedSecondPart,
]);
parsedYear = parsedDelimitedYear;
parsedMonth = parsedDelimitedMonth;
}
const normalizedParsedYear = parsedYear ? normalizeExpiryYearFormat(parsedYear) : null;
const normalizedParsedMonth = parsedMonth?.replace(/^0+/, "").slice(0, 2);
// Set "empty" values to null
parsedYear = normalizedParsedYear?.length ? normalizedParsedYear : null;
parsedMonth = normalizedParsedMonth?.length ? normalizedParsedMonth : null;
return [parsedYear, parsedMonth];
}