feat: complete TASK-WP0-03 browser gate
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user