packages/fxa-shared/lib/user-agent.ts (176 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export type ParsedUa = {
family: string | null;
major: string | null;
minor: string | null;
patch: string | null;
toVersionString: () => string;
};
export type ParsedOs = ParsedUa & {
patchMinor: string | null;
};
export type ParsedDevice = {
family: string | null;
brand: string | null;
model: string | null;
};
export type ParsedUserAgentProperties = {
userAgent: string;
ua: ParsedUa;
os: ParsedOs;
device: ParsedDevice;
};
export type UAScalarProperties = {
browser: string | null;
browserVersion: string | null;
os: string | null;
osVersion: string | null;
deviceType: string | null;
formFactor: string | null;
};
// Safe wrapper around node-uap, which prevents unsafe input from
// leaking back to the result data.
// @ts-ignore
import * as ua from 'node-uap';
// We know this won't match "Symbian^3", "UI/WKWebView" or "Mail.ru" but
// it's simpler and safer to limit to alphanumerics, underscore and space.
const VALID_FAMILY_OR_NAME = /^[\w ]{1,32}$/;
const VALID_VERSION = /^[\w.]{1,16}$/;
// $1 = 'Firefox' indicates Firefox Sync, 'Mobile' indicates Sync mobile library
// $2 = OS
// $3 = application version
// $4 = form factor
// $5 = OS version
// $6 = application name
const SYNC_USER_AGENT =
/^(Firefox|Mobile)-(\w+)-(?:FxA(?:ccounts)?|Sync)\/([^\sb]*)(?:b\S+)? ?(?:\(([\w\s]+); [\w\s]+ ([^\s()]+)\))?(?: \((.+)\))?$/;
const MOBILE_OS_FAMILIES = new Set(['Android', 'iOS']);
const MOBILE_UA_OS_FAMILIES = new Set(['Firefox iOS']);
export const parse = (
userAgentString: string | undefined
): ParsedUserAgentProperties => {
const result = ua.parse(userAgentString);
safeFamily(result.ua);
safeVersion(result.ua);
safeFamily(result.os);
safeVersion(result.os);
return result;
};
export const parseToScalars = (
userAgentString: string | undefined
): UAScalarProperties => {
const matches = SYNC_USER_AGENT.exec(userAgentString || '');
if (matches && matches.length > 2) {
// Always parse known Sync user-agents ourselves,
// because node-uap makes a pig's ear of it.
return {
browser: safeReturnName(matches[6] || matches[1]),
browserVersion: safeReturnVersion(matches[3]),
os: safeReturnName(matches[2]),
osVersion: safeReturnVersion(matches[5]),
deviceType: marshallDeviceType(matches[4]),
formFactor: safeReturnName(matches[4]),
};
}
const parsed = parse(userAgentString);
return {
browser: safeReturnName(getFamily(parsed.ua)),
browserVersion: safeReturnVersion(parsed.ua.toVersionString()),
os: safeReturnName(getFamily(parsed.os)),
osVersion: safeReturnVersion(parsed.os.toVersionString()),
deviceType: getDeviceType(parsed) || null,
formFactor: safeReturnName(getFormFactor(parsed)),
};
};
export const isToVersionStringSupported = (
result: ParsedUserAgentProperties
): boolean => {
if (!result) {
result = parse(
'Mozilla/5.0 (Macintosh; Intel Mac OS X 10.13; rv:65.0) Gecko/20100101 Firefox/65.0'
);
}
if (!result || !result.os || !result.ua) {
return false;
}
if (typeof result.os.toVersionString !== 'function') {
return false;
}
if (typeof result.ua.toVersionString !== 'function') {
return false;
}
return true;
};
function safeReturnString(str: string, regex: RegExp): string | null {
return regex.test(str) ? str : null;
}
function safeReturnName(str: string): string | null {
return safeReturnString(str, VALID_FAMILY_OR_NAME);
}
function safeReturnVersion(str: string): string | null {
return safeReturnString(str, VALID_VERSION);
}
function safeFamily(parent: ParsedUa) {
if (!VALID_FAMILY_OR_NAME.test(parent.family as string)) {
parent.family = null;
}
}
function safeVersion(parent: ParsedOs) {
if (
parent &&
parent.toVersionString &&
!VALID_VERSION.test(parent.toVersionString())
) {
parent.major = parent.minor = parent.patch = parent.patchMinor = null;
}
}
function getFamily(data: ParsedUa | ParsedOs | ParsedDevice) {
return data.family && data.family !== 'Other' ? data.family : '';
}
function getDeviceType(data: ParsedUserAgentProperties) {
if (getFamily(data.device) || isMobileOS(data)) {
if (isTablet(data)) {
return 'tablet';
} else {
return 'mobile';
}
}
return null;
}
function isMobileOS(data: ParsedUserAgentProperties) {
return (
MOBILE_OS_FAMILIES.has(data.os.family || '') ||
MOBILE_UA_OS_FAMILIES.has(data.ua.family || '')
);
}
function isTablet(data: ParsedUserAgentProperties) {
return isIpad(data) || isAndroidTablet(data) || isGenericTablet(data);
}
function isIpad(data: ParsedUserAgentProperties) {
return (
/iPad/.test(data.device.family || '') || isDesktopUaOnIpadFirefox(data)
);
}
// iPads using FF iOS 13+ send a desktop UA.
// The OS shows as a Mac, but 'Firefox iOS' in the UA family.
function isDesktopUaOnIpadFirefox(data: ParsedUserAgentProperties) {
return (
/Mac/.test(data.os.family || '') &&
MOBILE_UA_OS_FAMILIES.has(data.ua.family || '')
);
}
function isAndroidTablet(data: ParsedUserAgentProperties) {
return (
data.os.family === 'Android' &&
data.userAgent.indexOf('Mobile') === -1 &&
data.userAgent.indexOf('AndroidSync') === -1
);
}
function isGenericTablet(data: ParsedUserAgentProperties) {
return data.device.brand === 'Generic' && data.device.model === 'Tablet';
}
function getFormFactor(data: ParsedUserAgentProperties) {
if (isDesktopUaOnIpadFirefox(data)) {
return 'iPad';
} else if (data.device.brand !== 'Generic') {
return getFamily(data.device);
}
return '';
}
function marshallDeviceType(formFactor: string) {
if (/iPad/.test(formFactor) || /tablet/i.test(formFactor)) {
return 'tablet';
}
return 'mobile';
}
export default {
parse,
parseToScalars,
isToVersionStringSupported,
safeName: safeReturnName,
safeVersion: safeReturnVersion,
};