Files
tyx_AI_xhs/apps/api/src/browser-support.ts

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 };
}