mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-19 14:17:20 +02:00
Add scoped completion exchange service transport
This commit is contained in:
1 parent
d909bf281b
commit
7fe60a89aa
7 files changed
+858
-60
No files matched your search
@@ -0,0 +1,262 @@
|
||||
import { randomBytes } from "crypto";
|
||||
import { RequestHandler } from "express";
|
||||
import {
|
||||
createReviewOwnerConsent,
|
||||
ReviewConsentError,
|
||||
} from "./review-owner-consent";
|
||||
import {
|
||||
createReviewServiceAuth,
|
||||
singleReviewHeader,
|
||||
} from "./review-service-auth";
|
||||
import { decodeReviewJSON } from "./review-json";
|
||||
|
||||
const contract = "4open.artifacts/1";
|
||||
const opaque = /^[a-f0-9]{32}$/;
|
||||
function exact(
|
||||
value: unknown,
|
||||
keys: string[],
|
||||
): value is Record<string, unknown> {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).sort().join(",") === keys.sort().join(",")
|
||||
);
|
||||
}
|
||||
function receipt(value: unknown, clientId: string, intentId: string): unknown {
|
||||
if (
|
||||
!exact(value, [
|
||||
"contract",
|
||||
"clientId",
|
||||
"intentId",
|
||||
"bindingId",
|
||||
"submissionRef",
|
||||
"policy",
|
||||
"entitlementId",
|
||||
]) ||
|
||||
value.contract !== contract ||
|
||||
value.clientId !== clientId ||
|
||||
value.intentId !== intentId ||
|
||||
![value.bindingId, value.submissionRef, value.entitlementId].every(
|
||||
(id) => typeof id === "string" && opaque.test(id),
|
||||
) ||
|
||||
!exact(value.policy, ["version", "access", "retainUntil"])
|
||||
)
|
||||
throw new Error("Invalid receipt");
|
||||
const policy = value.policy;
|
||||
if (
|
||||
!Number.isSafeInteger(policy.version) ||
|
||||
(policy.version as number) < 1 ||
|
||||
!["anonymous-link", "restricted-review"].includes(
|
||||
policy.access as string,
|
||||
) ||
|
||||
typeof policy.retainUntil !== "string" ||
|
||||
!/^2[01][0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(
|
||||
policy.retainUntil,
|
||||
) ||
|
||||
!Number.isFinite(Date.parse(policy.retainUntil)) ||
|
||||
new Date(policy.retainUntil).toISOString() !==
|
||||
policy.retainUntil.replace("Z", ".000Z")
|
||||
)
|
||||
throw new Error("Invalid receipt");
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Mount before global JSON parsing and browser authentication. Disabled without
|
||||
* its own scoped registry. This factory is intentionally not mounted at startup. */
|
||||
export function createReviewCompletionService(
|
||||
raw: string | undefined,
|
||||
backend: Pick<
|
||||
ReturnType<typeof createReviewOwnerConsent>,
|
||||
"exchange" | "receipt"
|
||||
>,
|
||||
now: () => number = Date.now,
|
||||
): RequestHandler {
|
||||
const auth =
|
||||
raw === undefined ? undefined : createReviewServiceAuth(raw, now);
|
||||
const rates = new Map<string, { minute: number; count: number }>();
|
||||
let active = 0;
|
||||
return async (req, res) => {
|
||||
res.set({
|
||||
"Cache-Control": "no-store",
|
||||
"Referrer-Policy": "no-referrer",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
});
|
||||
res.removeHeader("Access-Control-Allow-Origin");
|
||||
const fail = (status: number, code: string) => {
|
||||
if (res.headersSent || res.destroyed) return;
|
||||
res.setHeader("Connection", "close");
|
||||
res
|
||||
.status(status)
|
||||
.json({ contract, code, requestId: randomBytes(16).toString("hex") });
|
||||
};
|
||||
const exchange = req.originalUrl === "/service/v1/completions/exchange";
|
||||
const read = /^\/service\/v1\/intents\/([a-f0-9]{32})\/receipt$/.exec(
|
||||
req.originalUrl,
|
||||
);
|
||||
if (!auth || (!exchange && !read)) return fail(404, "not-found");
|
||||
if (req.method !== (exchange ? "POST" : "GET"))
|
||||
return fail(405, "invalid-request");
|
||||
if (
|
||||
!req.secure ||
|
||||
[
|
||||
"cookie",
|
||||
"origin",
|
||||
"referer",
|
||||
"sec-fetch-site",
|
||||
"sec-fetch-mode",
|
||||
"sec-fetch-dest",
|
||||
"sec-fetch-user",
|
||||
].some((name) => req.headers[name] !== undefined)
|
||||
)
|
||||
return fail(403, "forbidden");
|
||||
if (req.body !== undefined) return fail(503, "unavailable");
|
||||
if (req.headers["content-encoding"] !== undefined)
|
||||
return fail(400, "invalid-request");
|
||||
for (const name of ["content-type", "content-length", "transfer-encoding"])
|
||||
if (
|
||||
req.headers[name] !== undefined &&
|
||||
singleReviewHeader(req, name) === undefined
|
||||
)
|
||||
return fail(400, "invalid-request");
|
||||
if (exchange) {
|
||||
if (
|
||||
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
|
||||
singleReviewHeader(req, "content-type") || "",
|
||||
)
|
||||
)
|
||||
return fail(400, "invalid-request");
|
||||
const length = req.headers["content-length"];
|
||||
if (
|
||||
length !== undefined &&
|
||||
(!/^\d+$/.test(String(length)) || Number(length) > 65536)
|
||||
)
|
||||
return fail(400, "invalid-request");
|
||||
} else if (
|
||||
req.headers["transfer-encoding"] !== undefined ||
|
||||
req.headers["content-type"] !== undefined ||
|
||||
(req.headers["content-length"] !== undefined &&
|
||||
req.headers["content-length"] !== "0")
|
||||
)
|
||||
return fail(400, "invalid-request");
|
||||
const principal = auth.authenticate(
|
||||
req,
|
||||
exchange ? "completion.exchange" : "receipt.read",
|
||||
);
|
||||
if (!principal) return fail(401, "unauthorized");
|
||||
const minute = Math.floor(now() / 60000);
|
||||
let rate = rates.get(principal.clientId);
|
||||
if (!rate || rate.minute !== minute) {
|
||||
rate = { minute, count: 0 };
|
||||
rates.set(principal.clientId, rate);
|
||||
}
|
||||
if (rate.count++ >= 60) return fail(429, "rate-limited");
|
||||
if (active >= 4) return fail(503, "unavailable");
|
||||
active++;
|
||||
let cancelBody: (() => void) | undefined;
|
||||
let ended = false;
|
||||
const close = () => {
|
||||
ended = true;
|
||||
cancelBody?.();
|
||||
};
|
||||
res.once("close", close);
|
||||
const timer = setTimeout(() => {
|
||||
ended = true;
|
||||
fail(503, "unavailable");
|
||||
cancelBody?.();
|
||||
}, 15000);
|
||||
try {
|
||||
let intentId = read?.[1] || "";
|
||||
let result: unknown;
|
||||
if (exchange) {
|
||||
const bytes = await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let size = 0;
|
||||
const cleanup = () => {
|
||||
req.off("data", data);
|
||||
req.off("end", end);
|
||||
req.off("aborted", abort);
|
||||
req.off("error", abort);
|
||||
cancelBody = undefined;
|
||||
};
|
||||
const abort = () => {
|
||||
cleanup();
|
||||
reject(new Error("Request unavailable"));
|
||||
};
|
||||
const data = (chunk: Buffer) => {
|
||||
size += chunk.length;
|
||||
if (size > 65536) {
|
||||
fail(400, "invalid-request");
|
||||
abort();
|
||||
} else chunks.push(chunk);
|
||||
};
|
||||
const end = () => {
|
||||
cleanup();
|
||||
resolve(Buffer.concat(chunks));
|
||||
};
|
||||
cancelBody = abort;
|
||||
req.on("data", data);
|
||||
req.once("end", end);
|
||||
req.once("aborted", abort);
|
||||
req.once("error", abort);
|
||||
});
|
||||
if (ended) return;
|
||||
let command: unknown;
|
||||
try {
|
||||
command = decodeReviewJSON(bytes);
|
||||
} catch {
|
||||
return fail(400, "invalid-request");
|
||||
}
|
||||
if (
|
||||
!exact(command, [
|
||||
"contract",
|
||||
"clientId",
|
||||
"intentId",
|
||||
"code",
|
||||
"requestId",
|
||||
]) ||
|
||||
typeof command.contract !== "string" ||
|
||||
![command.clientId, command.intentId, command.requestId].every(
|
||||
(id) => typeof id === "string" && opaque.test(id),
|
||||
) ||
|
||||
typeof command.code !== "string" ||
|
||||
!/^[a-f0-9]{64}$/.test(command.code)
|
||||
)
|
||||
return fail(400, "invalid-request");
|
||||
if (command.contract !== contract)
|
||||
return fail(422, "unsupported-version");
|
||||
if (command.clientId !== principal.clientId)
|
||||
return fail(403, "forbidden");
|
||||
if (!principal.valid()) return fail(401, "unauthorized");
|
||||
intentId = command.intentId as string;
|
||||
result = await backend.exchange(
|
||||
principal.clientId,
|
||||
command as Parameters<typeof backend.exchange>[1],
|
||||
);
|
||||
} else {
|
||||
if (!principal.valid()) return fail(401, "unauthorized");
|
||||
result = await backend.receipt(principal.clientId, intentId);
|
||||
}
|
||||
if (ended) return;
|
||||
if (!principal.valid()) return fail(401, "unauthorized");
|
||||
res.status(200).json(receipt(result, principal.clientId, intentId));
|
||||
} catch (error) {
|
||||
if (ended) return;
|
||||
if (error instanceof ReviewConsentError) {
|
||||
const codes = {
|
||||
invalid: [400, "invalid-request"],
|
||||
forbidden: [403, "forbidden"],
|
||||
expired: [410, "expired"],
|
||||
conflict: [409, "conflict"],
|
||||
unavailable: [503, "unavailable"],
|
||||
} as const;
|
||||
const [status, code] = codes[error.kind];
|
||||
fail(status, code);
|
||||
} else fail(503, "unavailable");
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
res.off("close", close);
|
||||
active--;
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { decodeReviewJSON } from "./review-json";
|
||||
import * as https from "https";
|
||||
import { ClientRequest } from "http";
|
||||
|
||||
@@ -81,70 +82,14 @@ function id(value: unknown): value is string {
|
||||
return typeof value === "string" && opaque.test(value);
|
||||
}
|
||||
|
||||
// JSON.parse checks syntax; this bounded walk additionally rejects duplicate
|
||||
// decoded keys, including escaped spellings, before typed field validation.
|
||||
function decode(bytes: Buffer): unknown {
|
||||
if (bytes.length > limit) fail("protocol");
|
||||
const text = bytes.toString("utf8");
|
||||
if (!Buffer.from(text, "utf8").equals(bytes)) fail("protocol");
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
return decodeReviewJSON(bytes);
|
||||
} catch {
|
||||
fail("protocol");
|
||||
}
|
||||
let at = 0;
|
||||
const space = () => {
|
||||
while (/\s/.test(text[at] || "") && at < text.length) at++;
|
||||
};
|
||||
const string = (): string => {
|
||||
const start = at++;
|
||||
while (at < text.length) {
|
||||
if (text[at] === "\\") {
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
if (text[at++] === '"') return JSON.parse(text.slice(start, at));
|
||||
}
|
||||
return fail("protocol");
|
||||
};
|
||||
const walk = (depth: number): void => {
|
||||
if (depth > 8) fail("protocol");
|
||||
space();
|
||||
if (text[at] === '"') {
|
||||
string();
|
||||
return;
|
||||
}
|
||||
if (text[at] === "{" || text[at] === "[") {
|
||||
const isObject = text[at++] === "{",
|
||||
end = isObject ? "}" : "]",
|
||||
keys = new Set<string>();
|
||||
space();
|
||||
if (text[at] === end) {
|
||||
at++;
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
space();
|
||||
if (isObject) {
|
||||
const key = string();
|
||||
if (keys.has(key)) fail("protocol");
|
||||
keys.add(key);
|
||||
space();
|
||||
at++; // colon; syntax already checked
|
||||
}
|
||||
walk(depth + 1);
|
||||
space();
|
||||
if (text[at++] === end) return;
|
||||
}
|
||||
}
|
||||
while (at < text.length && !/[\s,}\]]/.test(text[at])) at++;
|
||||
};
|
||||
walk(0);
|
||||
space();
|
||||
if (at !== text.length) fail("protocol");
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
function intent(
|
||||
bytes: Buffer,
|
||||
clientId: string,
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
function invalid(): never {
|
||||
throw new Error("Invalid review JSON");
|
||||
}
|
||||
|
||||
// JSON.parse checks syntax; this bounded walk additionally rejects duplicate
|
||||
// decoded keys, including escaped spellings, before typed field validation.
|
||||
export function decodeReviewJSON(bytes: Buffer): unknown {
|
||||
if (bytes.length > 65536) invalid();
|
||||
const text = bytes.toString("utf8");
|
||||
if (!Buffer.from(text, "utf8").equals(bytes)) invalid();
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
invalid();
|
||||
}
|
||||
let at = 0;
|
||||
const space = () => {
|
||||
while (/\s/.test(text[at] || "") && at < text.length) at++;
|
||||
};
|
||||
const string = (): string => {
|
||||
const start = at++;
|
||||
while (at < text.length) {
|
||||
if (text[at] === "\\") {
|
||||
at += 2;
|
||||
continue;
|
||||
}
|
||||
if (text[at++] === '"') return JSON.parse(text.slice(start, at));
|
||||
}
|
||||
return invalid();
|
||||
};
|
||||
const walk = (depth: number): void => {
|
||||
if (depth > 8) invalid();
|
||||
space();
|
||||
if (text[at] === '"') {
|
||||
string();
|
||||
return;
|
||||
}
|
||||
if (text[at] === "{" || text[at] === "[") {
|
||||
const isObject = text[at++] === "{",
|
||||
end = isObject ? "}" : "]",
|
||||
keys = new Set<string>();
|
||||
space();
|
||||
if (text[at] === end) {
|
||||
at++;
|
||||
return;
|
||||
}
|
||||
for (;;) {
|
||||
space();
|
||||
if (isObject) {
|
||||
const key = string();
|
||||
if (keys.has(key)) invalid();
|
||||
keys.add(key);
|
||||
space();
|
||||
at++; // colon; syntax already checked
|
||||
}
|
||||
walk(depth + 1);
|
||||
space();
|
||||
if (text[at++] === end) return;
|
||||
}
|
||||
}
|
||||
while (at < text.length && !/[\s,}\]]/.test(text[at])) at++;
|
||||
};
|
||||
walk(0);
|
||||
space();
|
||||
if (at !== text.length) invalid();
|
||||
return parsed;
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import { createHash, timingSafeEqual } from "crypto";
|
||||
import { Request } from "express";
|
||||
import { decodeReviewJSON } from "./review-json";
|
||||
|
||||
type Scope = "completion.exchange" | "receipt.read";
|
||||
type Key = {
|
||||
clientId: string;
|
||||
digest: Buffer;
|
||||
from: number;
|
||||
until: number;
|
||||
scopes: Scope[];
|
||||
};
|
||||
const opaque = /^[a-f0-9]{32}$/;
|
||||
const secret = /^[a-f0-9]{64}$/;
|
||||
const invalid = (): never => {
|
||||
throw new Error("Invalid review exchange credentials");
|
||||
};
|
||||
function fields(value: unknown, names: string[]): Record<string, unknown> {
|
||||
if (
|
||||
!value ||
|
||||
typeof value !== "object" ||
|
||||
Array.isArray(value) ||
|
||||
Object.keys(value).sort().join(",") !== names.sort().join(",")
|
||||
)
|
||||
return invalid();
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
function date(value: unknown): number {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
!/^20[0-9]{2}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z$/.test(value)
|
||||
)
|
||||
return invalid();
|
||||
const number = Date.parse(value);
|
||||
if (
|
||||
!Number.isFinite(number) ||
|
||||
new Date(number).toISOString() !== value.replace("Z", ".000Z")
|
||||
)
|
||||
return invalid();
|
||||
return number;
|
||||
}
|
||||
export function singleReviewHeader(
|
||||
req: Request,
|
||||
name: string,
|
||||
): string | undefined {
|
||||
let count = 0;
|
||||
for (let i = 0; i < req.rawHeaders.length; i += 2)
|
||||
if (req.rawHeaders[i].toLowerCase() === name) count++;
|
||||
const value = req.headers[name];
|
||||
return count === 1 && typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
/** Separate scoped registry: the capabilities-only registry is not accepted. */
|
||||
export function createReviewServiceAuth(
|
||||
raw: string,
|
||||
now: () => number = Date.now,
|
||||
) {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = decodeReviewJSON(Buffer.from(raw, "utf8"));
|
||||
} catch {
|
||||
return invalid();
|
||||
}
|
||||
const config = fields(parsed, ["version", "keys"]);
|
||||
if (
|
||||
config.version !== 1 ||
|
||||
!Array.isArray(config.keys) ||
|
||||
config.keys.length < 1 ||
|
||||
config.keys.length > 64
|
||||
)
|
||||
return invalid();
|
||||
const keys = new Map<string, Key>(),
|
||||
digests = new Set<string>(),
|
||||
clients = new Map<string, number>();
|
||||
for (const entry of config.keys) {
|
||||
const k = fields(entry, [
|
||||
"clientId",
|
||||
"keyId",
|
||||
"tokenSHA256",
|
||||
"notBefore",
|
||||
"notAfter",
|
||||
"scopes",
|
||||
]);
|
||||
if (
|
||||
typeof k.clientId !== "string" ||
|
||||
!opaque.test(k.clientId) ||
|
||||
typeof k.keyId !== "string" ||
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(k.keyId) ||
|
||||
typeof k.tokenSHA256 !== "string" ||
|
||||
!secret.test(k.tokenSHA256) ||
|
||||
!Array.isArray(k.scopes) ||
|
||||
k.scopes.length < 1 ||
|
||||
k.scopes.length > 2 ||
|
||||
k.scopes.some(
|
||||
(scope) => scope !== "completion.exchange" && scope !== "receipt.read",
|
||||
) ||
|
||||
new Set(k.scopes).size !== k.scopes.length
|
||||
)
|
||||
return invalid();
|
||||
const from = date(k.notBefore),
|
||||
until = date(k.notAfter),
|
||||
id = k.clientId + ":" + k.keyId;
|
||||
const count = (clients.get(k.clientId) || 0) + 1;
|
||||
if (
|
||||
until <= from ||
|
||||
keys.has(id) ||
|
||||
digests.has(k.tokenSHA256) ||
|
||||
count > 16
|
||||
)
|
||||
return invalid();
|
||||
clients.set(k.clientId, count);
|
||||
if (clients.size > 32) return invalid();
|
||||
digests.add(k.tokenSHA256);
|
||||
keys.set(id, {
|
||||
clientId: k.clientId,
|
||||
digest: Buffer.from(k.tokenSHA256, "hex"),
|
||||
from,
|
||||
until,
|
||||
scopes: [...k.scopes] as Scope[],
|
||||
});
|
||||
}
|
||||
const dummy = Buffer.alloc(32);
|
||||
return Object.freeze({
|
||||
authenticate(
|
||||
req: Request,
|
||||
scope: Scope,
|
||||
): { clientId: string; valid: () => boolean } | undefined {
|
||||
const client = singleReviewHeader(req, "x-4open-artifact-client-id"),
|
||||
keyId = singleReviewHeader(req, "x-4open-artifact-service-key-id"),
|
||||
authorization = singleReviewHeader(req, "authorization");
|
||||
if (
|
||||
!client ||
|
||||
!opaque.test(client) ||
|
||||
!keyId ||
|
||||
!/^[A-Za-z0-9_-]{1,64}$/.test(keyId) ||
|
||||
!authorization ||
|
||||
!/^Bearer [a-f0-9]{64}$/i.test(authorization) ||
|
||||
!secret.test(authorization.slice(7))
|
||||
)
|
||||
return undefined;
|
||||
const key = keys.get(client + ":" + keyId);
|
||||
const matches = timingSafeEqual(
|
||||
createHash("sha256").update(authorization.slice(7), "ascii").digest(),
|
||||
key?.digest || dummy,
|
||||
);
|
||||
const valid = () => {
|
||||
const time = now();
|
||||
return (
|
||||
!!key &&
|
||||
matches &&
|
||||
key.scopes.includes(scope) &&
|
||||
Number.isFinite(time) &&
|
||||
time >= key.from &&
|
||||
time < key.until
|
||||
);
|
||||
};
|
||||
return valid() ? Object.freeze({ clientId: client, valid }) : undefined;
|
||||
},
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user