feat: complete TASK-WP0-03 browser gate

This commit is contained in:
suyx
2026-07-27 18:23:20 +08:00
parent dbe3e73b91
commit 878788b1e2
25 changed files with 3227 additions and 21 deletions
+184 -4
View File
@@ -1,4 +1,6 @@
import { randomUUID } from "node:crypto";
import { randomBytes, randomUUID } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import {
BootstrapResponseSchema,
@@ -11,13 +13,28 @@ import {
SseEventSchema,
StableEngineeringErrorCodeSchema,
StateSseEventSchema,
createErrorEnvelope,
isCorrelationId,
type BootstrapResponse,
} from "@dada/shared-contracts";
import swagger from "@fastify/swagger";
import Fastify from "fastify";
import Fastify, { type FastifyReply } from "fastify";
import {
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BrowserUnsupportedReasonSchema,
browserSupportCookieMaxAgeSeconds,
browserSupportCookieName,
checkBrowserSupport,
createBrowserSupportCookie,
supportedBrowserSummary,
verifyBrowserSupportCookie,
type BrowserSupportRelease,
type BrowserUnsupportedReason,
} from "./browser-support.js";
import { EventHub } from "./event-hub.js";
import { isAllowedNetworkRequest, type NetworkBoundaryOptions } from "./network-boundary.js";
const defaultBootstrap: BootstrapResponse = {
app_version: "0.0.0",
@@ -33,18 +50,80 @@ const defaultBootstrap: BootstrapResponse = {
export interface CreateAppOptions {
bootstrap?: () => BootstrapResponse | Promise<BootstrapResponse>;
browserGate?: boolean;
browserSupportRelease?: BrowserSupportRelease;
browserSupportSecret?: Buffer;
eventHub?: EventHub;
networkBoundary?: NetworkBoundaryOptions;
}
const supportGateDirectory = resolve("apps/web/support-gate");
const supportGateHtml = readFileSync(resolve(supportGateDirectory, "index.html"), "utf8");
const supportGateCss = readFileSync(resolve(supportGateDirectory, "support-gate.css"), "utf8");
const supportGateJavaScript = readFileSync(resolve(supportGateDirectory, "support-gate.js"), "utf8");
const clientHints = "Sec-CH-UA, Sec-CH-UA-Full-Version-List, Sec-CH-UA-Platform";
const contentSecurityPolicy = [
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' blob:",
"font-src 'self' blob:",
"connect-src 'self'",
"object-src 'none'",
"base-uri 'none'",
"frame-ancestors 'none'",
].join("; ");
function requestCorrelationId(headers: Record<string, string | string[] | undefined>) {
const header = headers["x-correlation-id"];
const candidate = Array.isArray(header) ? header[0] : header;
return isCorrelationId(candidate) ? candidate : randomUUID();
}
function headerValue(value: string | string[] | undefined) {
return Array.isArray(value) ? value[0] : value;
}
function isSupportGateRequest(method: string, path: string) {
if (method === "POST" && path === "/api/v1/support/check") return true;
if (method !== "GET" && method !== "HEAD") return false;
return (
path === "/" ||
path === "/app" ||
path.startsWith("/app/") ||
path === "/admin" ||
path.startsWith("/admin/") ||
path === "/support-gate.css" ||
path === "/support-gate.js" ||
path === "/RELEASE.json" ||
path === "/healthz"
);
}
function sendBrowserUnsupported(
reply: FastifyReply,
correlationId: string,
reason: BrowserUnsupportedReason,
release: BrowserSupportRelease | undefined,
) {
return reply.code(426).send(
createErrorEnvelope({
code: "BROWSER_UNSUPPORTED",
correlationId,
details: {
reason,
supported_browsers: supportedBrowserSummary(release),
},
}),
);
}
export async function createApp(options: CreateAppOptions = {}) {
const eventHub = options.eventHub ?? new EventHub();
const bootstrap = options.bootstrap ?? (() => defaultBootstrap);
const browserGate = options.browserGate ?? true;
const browserSupportSecret = options.browserSupportSecret ?? randomBytes(32);
const browserSupportRelease = options.browserSupportRelease;
const app = Fastify({
genReqId: (request) => requestCorrelationId(request.headers),
logger: false,
@@ -70,6 +149,9 @@ export async function createApp(options: CreateAppOptions = {}) {
StableEngineeringErrorCodeSchema,
ErrorDetailsSchema,
ErrorEnvelopeSchema,
BrowserUnsupportedReasonSchema,
BrowserSupportRequestSchema,
BrowserSupportSuccessSchema,
BootstrapResponseSchema,
StateSseEventSchema,
ModelConfigSseEventSchema,
@@ -79,11 +161,107 @@ export async function createApp(options: CreateAppOptions = {}) {
app.addSchema(schema);
}
app.addHook("onRequest", (request, reply, done) => {
app.addHook("onRequest", async (request, reply) => {
reply.header("X-Correlation-Id", request.id);
done();
reply.header("Accept-CH", clientHints);
reply.header("Cache-Control", "no-store");
reply.header("Content-Security-Policy", contentSecurityPolicy);
reply.header("X-Content-Type-Options", "nosniff");
const host = headerValue(request.headers.host);
const origin = headerValue(request.headers.origin);
if (!isAllowedNetworkRequest({ host, method: request.method, origin }, options.networkBoundary)) {
return sendBrowserUnsupported(reply, request.id, "identity_unavailable", browserSupportRelease);
}
const path = request.url.split("?", 1)[0] ?? "/";
if (!browserGate || isSupportGateRequest(request.method, path)) return;
const verified = verifyBrowserSupportCookie({
cookieHeader: headerValue(request.headers.cookie),
release: browserSupportRelease,
secChUa: headerValue(request.headers["sec-ch-ua"]),
secret: browserSupportSecret,
});
if (!verified.supported) {
return sendBrowserUnsupported(reply, request.id, verified.reason, browserSupportRelease);
}
});
for (const route of ["/", "/app", "/app/*", "/admin", "/admin/*"]) {
app.get(route, { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/html; charset=utf-8");
return supportGateHtml;
});
}
app.get("/support-gate.css", { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/css; charset=utf-8");
return supportGateCss;
});
app.get("/support-gate.js", { schema: { hide: true } }, async (_request, reply) => {
reply.type("text/javascript; charset=utf-8");
return supportGateJavaScript;
});
app.get("/RELEASE.json", { schema: { hide: true } }, async () => ({
app_version: browserSupportRelease?.appVersion ?? null,
browsers: supportedBrowserSummary(browserSupportRelease),
}));
app.get("/healthz", { schema: { hide: true } }, async () => ({
bind_scope: "loopback",
port: 43121,
status: "ready",
}));
app.post(
"/api/v1/support/check",
{
attachValidation: true,
schema: {
body: BrowserSupportRequestSchema,
operationId: "checkBrowserSupport",
response: {
200: BrowserSupportSuccessSchema,
426: ErrorEnvelopeSchema,
},
tags: ["Browser support"],
},
},
async (request, reply) => {
const checked = checkBrowserSupport(
request.validationError ? undefined : request.body,
{
secChUa: headerValue(request.headers["sec-ch-ua"]),
secChUaFullVersionList: headerValue(request.headers["sec-ch-ua-full-version-list"]),
secChUaPlatform: headerValue(request.headers["sec-ch-ua-platform"]),
},
browserSupportRelease,
);
if (!checked.supported || !browserSupportRelease) {
return sendBrowserUnsupported(
reply,
request.id,
checked.supported ? "version_unsupported" : checked.reason,
browserSupportRelease,
);
}
const cookie = createBrowserSupportCookie(
browserSupportSecret,
browserSupportRelease,
checked.identity,
);
reply.header(
"Set-Cookie",
`${browserSupportCookieName}=${cookie}; Max-Age=${browserSupportCookieMaxAgeSeconds}; Path=/; HttpOnly; SameSite=Strict`,
);
return {
app_version: browserSupportRelease.appVersion,
browser: checked.identity,
status: "supported" as const,
supported_browsers: supportedBrowserSummary(browserSupportRelease),
};
},
);
app.get(
"/api/v1/bootstrap",
{
@@ -91,6 +269,7 @@ export async function createApp(options: CreateAppOptions = {}) {
operationId: "getBootstrap",
response: {
200: BootstrapResponseSchema,
426: ErrorEnvelopeSchema,
},
tags: ["Bootstrap"],
},
@@ -112,6 +291,7 @@ export async function createApp(options: CreateAppOptions = {}) {
},
description: "Non-sensitive state change hints. REST remains authoritative.",
},
426: ErrorEnvelopeSchema,
},
tags: ["State events"],
},
+275
View File
@@ -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 };
}
+6 -2
View File
@@ -1,6 +1,10 @@
import { createApp } from "./app.js";
import { resolve } from "node:path";
const app = await createApp();
import { createApp } from "./app.js";
import { readBrowserSupportRelease } from "./browser-support.js";
const browserSupportRelease = readBrowserSupportRelease(resolve("RELEASE.json"));
const app = await createApp(browserSupportRelease ? { browserSupportRelease } : {});
await app.listen({
host: "127.0.0.1",
+27
View File
@@ -0,0 +1,27 @@
export const DADA_LOOPBACK_HOST = "127.0.0.1";
export const DADA_LOOPBACK_PORT = 43121;
export interface NetworkBoundaryOptions {
allowTestPort?: boolean;
}
interface NetworkRequest {
host: string | undefined;
method: string;
origin: string | undefined;
}
export function isAllowedNetworkRequest(
request: NetworkRequest,
options: NetworkBoundaryOptions = {},
) {
if (request.method === "OPTIONS" || !request.host) return false;
const allowedHost = options.allowTestPort
? /^127\.0\.0\.1:[1-9][0-9]{0,4}$/.test(request.host)
: request.host === `${DADA_LOOPBACK_HOST}:${DADA_LOOPBACK_PORT}`;
if (!allowedHost) return false;
if (request.origin !== undefined && request.origin !== `http://${request.host}`) return false;
if (!["GET", "HEAD"].includes(request.method) && request.origin === undefined) return false;
return true;
}