mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-19 06:07:21 +02:00
Return artifact consent through registered POST callbacks
This commit is contained in:
1 parent
09f94137cd
commit
d3a8b4a8a7
11 files changed
+202
-13
No files matched your search
@@ -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" ||
|
||||
|
||||
@@ -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>;
|
||||
@@ -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" ||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user