276 lines
10 KiB
TypeScript
276 lines
10 KiB
TypeScript
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
import { readFileSync } from "node:fs";
|
|
|
|
import { Type, type Static } from "@sinclair/typebox";
|
|
import { Value } from "@sinclair/typebox/value";
|
|
|
|
export const browserSupportCookieName = "dada_browser_support";
|
|
export const browserSupportCookieMaxAgeSeconds = 24 * 60 * 60;
|
|
|
|
export const BrowserUnsupportedReasonSchema = Type.Union(
|
|
[
|
|
Type.Literal("platform_unsupported"),
|
|
Type.Literal("brand_unsupported"),
|
|
Type.Literal("version_unsupported"),
|
|
Type.Literal("identity_unavailable"),
|
|
],
|
|
{ $id: "BrowserUnsupportedReason" },
|
|
);
|
|
const BrowserBrandVersionSchema = Type.Object(
|
|
{
|
|
brand: Type.String({ maxLength: 80 }),
|
|
version: Type.String({ maxLength: 80 }),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
const SupportedBrowserSummarySchema = Type.Object(
|
|
{
|
|
brand: Type.Union([Type.Literal("Google Chrome"), Type.Literal("Microsoft Edge")]),
|
|
major: Type.Integer({ minimum: 1 }),
|
|
},
|
|
{ additionalProperties: false },
|
|
);
|
|
export const BrowserSupportRequestSchema = Type.Object(
|
|
{
|
|
brands: Type.Array(BrowserBrandVersionSchema, { maxItems: 16 }),
|
|
full_version_list: Type.Array(BrowserBrandVersionSchema, { maxItems: 16 }),
|
|
platform: Type.String({ maxLength: 40 }),
|
|
},
|
|
{ additionalProperties: false, $id: "BrowserSupportRequest" },
|
|
);
|
|
export const BrowserSupportSuccessSchema = Type.Object(
|
|
{
|
|
app_version: Type.String({ maxLength: 80 }),
|
|
browser: SupportedBrowserSummarySchema,
|
|
status: Type.Literal("supported"),
|
|
supported_browsers: Type.Array(SupportedBrowserSummarySchema, { maxItems: 2 }),
|
|
},
|
|
{ additionalProperties: false, $id: "BrowserSupportSuccess" },
|
|
);
|
|
|
|
export type BrowserUnsupportedReason = Static<typeof BrowserUnsupportedReasonSchema>;
|
|
export type BrowserSupportRequest = Static<typeof BrowserSupportRequestSchema>;
|
|
type SupportedBrand = "Google Chrome" | "Microsoft Edge";
|
|
|
|
export interface BrowserSupportRelease {
|
|
appVersion: string;
|
|
browsers: ReadonlyArray<{
|
|
brand: SupportedBrand;
|
|
fullVersion: string;
|
|
}>;
|
|
}
|
|
|
|
interface BrowserIdentity {
|
|
brand: SupportedBrand;
|
|
major: number;
|
|
}
|
|
|
|
type BrowserSupportResult =
|
|
| { identity: BrowserIdentity; supported: true }
|
|
| { reason: BrowserUnsupportedReason; supported: false };
|
|
|
|
const supportedBrands = new Set<SupportedBrand>(["Google Chrome", "Microsoft Edge"]);
|
|
const fullVersionPattern = /^[1-9][0-9]*\.[0-9]+\.[0-9]+\.[0-9]+$/;
|
|
|
|
function major(version: string) {
|
|
if (!/^[1-9][0-9]*(?:\.[0-9]+)*$/.test(version)) return undefined;
|
|
const first = version.split(".")[0];
|
|
if (!first) return undefined;
|
|
const value = Number.parseInt(first, 10);
|
|
return Number.isSafeInteger(value) ? value : undefined;
|
|
}
|
|
|
|
function parsedHeaderList(value: string | undefined) {
|
|
if (!value) return undefined;
|
|
const entries: Array<{ brand: string; version: string }> = [];
|
|
const remainder = value.replace(/"([^"]+)"\s*;\s*v="([^"]+)"/g, (_match, brand, version) => {
|
|
entries.push({ brand, version });
|
|
return "";
|
|
});
|
|
if (entries.length === 0 || !/^[\s,]*$/.test(remainder)) return undefined;
|
|
return entries;
|
|
}
|
|
|
|
function parsedPlatform(value: string | undefined) {
|
|
const match = value?.match(/^"([^"]+)"$/);
|
|
return match?.[1];
|
|
}
|
|
|
|
function normalizedList(entries: Array<{ brand: string; version: string }>) {
|
|
return [...entries]
|
|
.sort((left, right) => left.brand.localeCompare(right.brand) || left.version.localeCompare(right.version))
|
|
.map(({ brand, version }) => `${brand}\u0000${version}`)
|
|
.join("\u0001");
|
|
}
|
|
|
|
function supportedIdentity(entries: Array<{ brand: string; version: string }>) {
|
|
const matches = entries.filter(({ brand }) => supportedBrands.has(brand as SupportedBrand));
|
|
if (matches.length !== 1) return undefined;
|
|
const entry = matches[0];
|
|
if (!entry) return undefined;
|
|
const parsedMajor = major(entry.version);
|
|
if (!parsedMajor) return undefined;
|
|
return { brand: entry.brand as SupportedBrand, major: parsedMajor };
|
|
}
|
|
|
|
export function supportedBrowserSummary(release: BrowserSupportRelease | undefined) {
|
|
if (!release) return [];
|
|
return release.browsers.map(({ brand, fullVersion }) => ({ brand, major: major(fullVersion)! }));
|
|
}
|
|
|
|
export function validateBrowserSupportRelease(value: unknown): value is BrowserSupportRelease {
|
|
if (!value || typeof value !== "object") return false;
|
|
const release = value as BrowserSupportRelease;
|
|
if (typeof release.appVersion !== "string" || release.appVersion.length === 0 || release.appVersion.length > 80) {
|
|
return false;
|
|
}
|
|
if (!Array.isArray(release.browsers) || release.browsers.length !== 2) return false;
|
|
const brands = new Set(release.browsers.map(({ brand }) => brand));
|
|
return (
|
|
brands.size === 2 &&
|
|
brands.has("Google Chrome") &&
|
|
brands.has("Microsoft Edge") &&
|
|
release.browsers.every(
|
|
({ brand, fullVersion }) => supportedBrands.has(brand) && fullVersionPattern.test(fullVersion),
|
|
)
|
|
);
|
|
}
|
|
|
|
export function readBrowserSupportRelease(path: string) {
|
|
try {
|
|
const value = JSON.parse(readFileSync(path, "utf8")) as unknown;
|
|
return validateBrowserSupportRelease(value) ? value : undefined;
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
export function checkBrowserSupport(
|
|
body: unknown,
|
|
headers: {
|
|
secChUa: string | undefined;
|
|
secChUaFullVersionList: string | undefined;
|
|
secChUaPlatform: string | undefined;
|
|
},
|
|
release: BrowserSupportRelease | undefined,
|
|
): BrowserSupportResult {
|
|
if (!Value.Check(BrowserSupportRequestSchema, body)) {
|
|
return { reason: "identity_unavailable", supported: false };
|
|
}
|
|
const request = body as BrowserSupportRequest;
|
|
const headerBrands = parsedHeaderList(headers.secChUa);
|
|
const headerFullVersions = parsedHeaderList(headers.secChUaFullVersionList);
|
|
const headerPlatform = parsedPlatform(headers.secChUaPlatform);
|
|
if (!headerBrands || !headerFullVersions || !headerPlatform) {
|
|
return { reason: "identity_unavailable", supported: false };
|
|
}
|
|
if (
|
|
normalizedList(headerBrands) !== normalizedList(request.brands) ||
|
|
normalizedList(headerFullVersions) !== normalizedList(request.full_version_list) ||
|
|
headerPlatform !== request.platform
|
|
) {
|
|
return { reason: "identity_unavailable", supported: false };
|
|
}
|
|
if (request.platform !== "Windows") return { reason: "platform_unsupported", supported: false };
|
|
|
|
const lowIdentity = supportedIdentity(request.brands);
|
|
const fullIdentity = supportedIdentity(request.full_version_list);
|
|
if (!lowIdentity && request.brands.every(({ brand }) => !supportedBrands.has(brand as SupportedBrand))) {
|
|
return { reason: "brand_unsupported", supported: false };
|
|
}
|
|
if (
|
|
!lowIdentity ||
|
|
!fullIdentity ||
|
|
lowIdentity.brand !== fullIdentity.brand ||
|
|
lowIdentity.major !== fullIdentity.major
|
|
) {
|
|
return { reason: "identity_unavailable", supported: false };
|
|
}
|
|
|
|
const supported = release?.browsers.find(({ brand }) => brand === fullIdentity.brand);
|
|
if (!supported || major(supported.fullVersion) !== fullIdentity.major) {
|
|
return { reason: "version_unsupported", supported: false };
|
|
}
|
|
return { identity: fullIdentity, supported: true };
|
|
}
|
|
|
|
function signature(secret: Buffer, encodedPayload: string) {
|
|
return createHmac("sha256", secret).update(encodedPayload).digest("base64url");
|
|
}
|
|
|
|
export function createBrowserSupportCookie(
|
|
secret: Buffer,
|
|
release: BrowserSupportRelease,
|
|
identity: BrowserIdentity,
|
|
issuedAtSeconds = Math.floor(Date.now() / 1000),
|
|
) {
|
|
const encodedPayload = Buffer.from(
|
|
JSON.stringify({
|
|
app_version: release.appVersion,
|
|
brand: identity.brand,
|
|
issued_at: issuedAtSeconds,
|
|
major: identity.major,
|
|
}),
|
|
).toString("base64url");
|
|
return `${encodedPayload}.${signature(secret, encodedPayload)}`;
|
|
}
|
|
|
|
function cookieValue(cookieHeader: string | undefined) {
|
|
for (const pair of cookieHeader?.split(";") ?? []) {
|
|
const [name, ...rest] = pair.trim().split("=");
|
|
if (name === browserSupportCookieName) return rest.join("=");
|
|
}
|
|
return undefined;
|
|
}
|
|
|
|
export function verifyBrowserSupportCookie(input: {
|
|
cookieHeader: string | undefined;
|
|
nowSeconds?: number;
|
|
release: BrowserSupportRelease | undefined;
|
|
secChUa: string | undefined;
|
|
secret: Buffer;
|
|
}) {
|
|
if (!input.release) return { reason: "version_unsupported" as const, supported: false as const };
|
|
const value = cookieValue(input.cookieHeader);
|
|
if (!value) return { reason: "identity_unavailable" as const, supported: false as const };
|
|
const [encodedPayload, encodedSignature, extra] = value.split(".");
|
|
if (!encodedPayload || !encodedSignature || extra) {
|
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
|
}
|
|
const expected = Buffer.from(signature(input.secret, encodedPayload));
|
|
const actual = Buffer.from(encodedSignature);
|
|
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
|
}
|
|
|
|
let payload: { app_version?: unknown; brand?: unknown; issued_at?: unknown; major?: unknown };
|
|
try {
|
|
payload = JSON.parse(Buffer.from(encodedPayload, "base64url").toString("utf8"));
|
|
} catch {
|
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
|
}
|
|
const now = input.nowSeconds ?? Math.floor(Date.now() / 1000);
|
|
if (
|
|
payload.app_version !== input.release.appVersion ||
|
|
!supportedBrands.has(payload.brand as SupportedBrand) ||
|
|
!Number.isSafeInteger(payload.major) ||
|
|
!Number.isSafeInteger(payload.issued_at) ||
|
|
(payload.issued_at as number) > now ||
|
|
now - (payload.issued_at as number) > browserSupportCookieMaxAgeSeconds
|
|
) {
|
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
|
}
|
|
|
|
const headerBrands = parsedHeaderList(input.secChUa);
|
|
const currentIdentity = headerBrands ? supportedIdentity(headerBrands) : undefined;
|
|
if (!currentIdentity) return { reason: "identity_unavailable" as const, supported: false as const };
|
|
if (currentIdentity.brand !== payload.brand) {
|
|
return { reason: "identity_unavailable" as const, supported: false as const };
|
|
}
|
|
const supported = input.release.browsers.find(({ brand }) => brand === currentIdentity.brand);
|
|
if (currentIdentity.major !== payload.major || major(supported?.fullVersion ?? "") !== currentIdentity.major) {
|
|
return { reason: "version_unsupported" as const, supported: false as const };
|
|
}
|
|
return { identity: currentIdentity, supported: true as const };
|
|
}
|