Return artifact consent through registered POST callbacks

This commit is contained in:
Thomas Durieux committed 2026-09-27 15:59:36 +00:00
1 parent 09f94137cd
commit d3a8b4a8a7
11 files changed
+202 -13

No files matched your search

+3 -1
View File
@@ -1,3 +1,4 @@
import { ReviewCallbacks } from "./service/review-callbacks";
import { Request, Response } from "express";
import { existsSync, readFileSync } from "fs";
import { resolve } from "path";
@@ -17,6 +18,7 @@ export function isReviewConsentPagePath(path: string): boolean {
export function createReviewConsentPage(
manifestPath = resolve("public", "asset-manifest.json"),
callbacks?: ReviewCallbacks,
) {
return (req: Request, res: Response): void => {
res.set({
@@ -25,7 +27,7 @@ export function createReviewConsentPage(
"X-Content-Type-Options": "nosniff",
"X-Frame-Options": "DENY",
"Content-Security-Policy":
"default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action 'self'; frame-ancestors 'none'",
"default-src 'self'; script-src 'self'; connect-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; frame-src 'none'; object-src 'none'; base-uri 'none'; form-action " + (callbacks?.formAction || "'self'") + "; frame-ancestors 'none'",
});
if (
req.originalUrl !== "/review-link" ||
+28
View File
@@ -0,0 +1,28 @@
import { decodeReviewJSON } from "./review-json";
const invalid = (): never => { throw new Error("Invalid review callback registry"); };
export function createReviewCallbacks(raw: string) {
let value: unknown;
try { value = decodeReviewJSON(Buffer.from(raw, "utf8")); } catch { return invalid(); }
if (!value || typeof value !== "object" || Array.isArray(value) || Object.keys(value).sort().join(",") !== "callbacks,version") return invalid();
const config = value as { version: unknown; callbacks: unknown };
if (config.version !== 1 || !Array.isArray(config.callbacks) || !config.callbacks.length || config.callbacks.length > 32) return invalid();
const callbacks = new Map<string, string>(), destinations = new Set<string>();
for (const item of config.callbacks) {
if (!item || typeof item !== "object" || Array.isArray(item) || Object.keys(item).sort().join(",") !== "callbackId,clientId,url") return invalid();
const { clientId, callbackId, url } = item;
if (![clientId, callbackId].every(id => typeof id === "string" && /^[a-f0-9]{32}$/.test(id)) || typeof url !== "string" || url.length > 2048) return invalid();
let parsed: URL;
try { parsed = new URL(url); } catch { return invalid(); }
if (parsed.protocol !== "https:" || parsed.href !== url || parsed.username || parsed.password || parsed.search || parsed.hash || url.includes("?") || url.includes("#") || !/^\/[A-Za-z0-9_-]+(?:\/[A-Za-z0-9_-]+)*$/.test(parsed.pathname)) return invalid();
const key = clientId + ":" + callbackId;
if (callbacks.has(key)) return invalid();
callbacks.set(key, url); destinations.add(url);
}
const formAction = "'self' " + [...destinations].join(" ");
if (Buffer.byteLength(formAction) > 4096) return invalid();
return Object.freeze({
formAction,
resolve(clientId: string, callbackId: string): string | undefined { return callbacks.get(clientId + ":" + callbackId); },
});
}
export type ReviewCallbacks = ReturnType<typeof createReviewCallbacks>;
+13 -4
View File
@@ -1,3 +1,4 @@
import { ReviewCallbacks } from "./review-callbacks";
import { createHmac, timingSafeEqual } from "crypto";
import * as express from "express";
import { Request, Response } from "express";
@@ -51,6 +52,7 @@ export function createReviewConsentRouter(
origin: string,
backend: ReturnType<typeof createReviewOwnerConsent>,
csrfKey: Buffer,
callbacks?: ReviewCallbacks,
) {
try {
if (new URL(origin).origin !== origin || !origin.startsWith("https://"))
@@ -77,7 +79,7 @@ export function createReviewConsentRouter(
res.setHeader("Referrer-Policy", "no-referrer");
res.setHeader("X-Content-Type-Options", "nosniff");
res.removeHeader("Access-Control-Allow-Origin");
if (!["/csrf", "/preview", "/confirm"].includes(req.url))
if (!["/csrf", "/preview", "/confirm", ...(callbacks ? ["/handoff"] : [])].includes(req.url))
return reject(res, 404, "not-found");
if (req.method !== (req.url === "/csrf" ? "GET" : "POST"))
return reject(res, 405, "invalid-request");
@@ -180,17 +182,24 @@ export function createReviewConsentRouter(
return reject(res, 401, "unauthorized");
// Derive without saving the session: a late CSRF response must never
// resurrect a session that a concurrent logout has destroyed.
res.json({ csrf: csrf(fresh).toString("hex") });
res.json({ csrf: csrf(fresh).toString("hex"), handoffEnabled: !!callbacks });
} catch {
reject(res, 503, "unavailable");
}
});
router.use(express.json({ limit: 12288, strict: true, inflate: false }));
router.post(["/preview", "/confirm"], async (req, res) => {
router.post(["/preview", "/confirm", "/handoff"], async (req, res) => {
const actor = res.locals.consentActor as Context;
try {
let result: unknown;
if (req.url === "/preview") {
if (req.url === "/handoff") {
if (!callbacks || !fields(req.body, ["ticket"]) || typeof req.body.ticket !== "string" || !req.body.ticket || req.body.ticket.length > 8192) return reject(res, 400, "invalid-request");
const result = await backend.handoff(actor, req.body.ticket, callbacks.resolve);
if (res.destroyed || res.headersSent) return;
const fresh = await current(req);
if (!fresh || fresh.accountId !== actor.accountId || fresh.sessionId !== actor.sessionId) return reject(res, 401, "unauthorized");
res.json({ completion: { contract: result.completion.contract, clientId: result.completion.clientId, intentId: result.completion.intentId, code: result.completion.code, expiresAt: result.completion.expiresAt }, callbackUrl: result.callbackUrl });
} else if (req.url === "/preview") {
if (
!fields(req.body, ["repositoryId", "intent"]) ||
typeof req.body.repositoryId !== "string" ||
+10 -2
View File
@@ -228,7 +228,14 @@ export function createReviewOwnerConsent(
createHmac("sha256", key)
.update("4open.review-completion/1." + id + "." + nonce)
.digest("hex");
return Object.freeze({
const api = {
async handoff(actor: Principal, ticket: string, resolveCallback: (clientId: string, callbackId: string) => string | undefined) {
const quote = decode(ticket, principal(actor));
const callbackUrl = resolveCallback(quote.intent.clientId, quote.intent.callbackId);
if (!callbackUrl) return deny("forbidden");
const completion = await api.completion(actor, ticket);
return Object.freeze({ completion, callbackUrl });
},
// Called only with the current authenticated browser principal. The HTTP
// adapter must retain its session reload, same-origin and CSRF checks.
async completion(actor: Principal, ticket: string) {
@@ -590,5 +597,6 @@ export function createReviewOwnerConsent(
},
);
},
});
};
return Object.freeze(api);
}