mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-18 21:57:20 +02:00
Protect artifact consent HTTP requests with current sessions and CSRF
This commit is contained in:
1 parent
5485f57e02
commit
7b60979c96
5 files changed
+838
-3
No files matched your search
@@ -0,0 +1,308 @@
|
||||
import { createHmac, timingSafeEqual } from "crypto";
|
||||
import * as express from "express";
|
||||
import { Request, Response } from "express";
|
||||
import { createReviewOwnerConsent } from "./review-owner-consent";
|
||||
|
||||
type BrowserSession = { passport?: { user?: unknown } };
|
||||
type Context = { accountId: string; sessionId: string };
|
||||
const opaque = /^[a-f0-9]{32}$/;
|
||||
const secret = /^[a-f0-9]{64}$/;
|
||||
function fields(
|
||||
value: unknown,
|
||||
names: string[],
|
||||
): value is Record<string, unknown> {
|
||||
return (
|
||||
!!value &&
|
||||
typeof value === "object" &&
|
||||
!Array.isArray(value) &&
|
||||
Object.keys(value).sort().join(",") === names.sort().join(",")
|
||||
);
|
||||
}
|
||||
function reject(res: Response, status: number, code: string) {
|
||||
if (!res.headersSent && !res.destroyed) res.setHeader("Connection", "close");
|
||||
if (!res.headersSent && !res.destroyed)
|
||||
res.status(status).json({ error: { code } });
|
||||
}
|
||||
async function current(req: Request): Promise<Context | undefined> {
|
||||
const id = (req.user as { user?: { _id?: unknown } } | undefined)?.user?._id;
|
||||
const accountId = id === undefined ? "" : String(id);
|
||||
if (
|
||||
!req.isAuthenticated?.() ||
|
||||
!/^[a-f0-9]{24}$/.test(accountId) ||
|
||||
!/^[A-Za-z0-9_-]{24,256}$/.test(req.sessionID || "")
|
||||
)
|
||||
return undefined;
|
||||
const session = await new Promise<BrowserSession | undefined>(
|
||||
(resolve, fail) =>
|
||||
req.sessionStore.get(req.sessionID, (error, value) =>
|
||||
error
|
||||
? fail(new Error("Session unavailable"))
|
||||
: resolve(value as BrowserSession | undefined),
|
||||
),
|
||||
);
|
||||
if (session?.passport?.user !== accountId) return undefined;
|
||||
return { accountId, sessionId: req.sessionID };
|
||||
}
|
||||
|
||||
/** Mount after current Passport/session authentication and BEFORE any JSON
|
||||
* parser. This factory is not registered by application startup. TLS must be
|
||||
* established directly or identified through the configured trusted proxy. */
|
||||
export function createReviewConsentRouter(
|
||||
origin: string,
|
||||
backend: ReturnType<typeof createReviewOwnerConsent>,
|
||||
csrfKey: Buffer,
|
||||
) {
|
||||
try {
|
||||
if (new URL(origin).origin !== origin || !origin.startsWith("https://"))
|
||||
throw new Error();
|
||||
} catch {
|
||||
throw new Error("Invalid review consent origin");
|
||||
}
|
||||
if (!Buffer.isBuffer(csrfKey) || csrfKey.length !== 32)
|
||||
throw new Error("Invalid review consent CSRF key");
|
||||
const key = Buffer.from(csrfKey);
|
||||
const csrf = (actor: Context) =>
|
||||
createHmac("sha256", key)
|
||||
.update(
|
||||
"4open.review-consent-csrf/1." +
|
||||
actor.accountId +
|
||||
"." +
|
||||
actor.sessionId,
|
||||
)
|
||||
.digest();
|
||||
const router = express.Router({ strict: true, caseSensitive: true });
|
||||
const rates = new Map<string, { until: number; count: number }>();
|
||||
router.use((req, res, next) => {
|
||||
res.setHeader("Cache-Control", "no-store");
|
||||
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))
|
||||
return reject(res, 404, "not-found");
|
||||
if (req.method !== (req.url === "/csrf" ? "GET" : "POST"))
|
||||
return reject(res, 405, "invalid-request");
|
||||
if (
|
||||
req.url === "/csrf" &&
|
||||
(req.headers["transfer-encoding"] !== undefined ||
|
||||
(req.headers["content-length"] !== undefined &&
|
||||
req.headers["content-length"] !== "0"))
|
||||
)
|
||||
return reject(res, 400, "invalid-request");
|
||||
if (
|
||||
!req.secure ||
|
||||
req.headers.authorization !== undefined ||
|
||||
req.headers["content-encoding"] !== undefined
|
||||
)
|
||||
return reject(res, 403, "forbidden");
|
||||
for (const name of [
|
||||
"origin",
|
||||
"content-type",
|
||||
"x-review-csrf",
|
||||
"sec-fetch-site",
|
||||
]) {
|
||||
if (
|
||||
req.rawHeaders.filter((v, i) => i % 2 === 0 && v.toLowerCase() === name)
|
||||
.length > 1
|
||||
)
|
||||
return reject(res, 403, "forbidden");
|
||||
}
|
||||
if (
|
||||
(req.headers.origin !== undefined && req.headers.origin !== origin) ||
|
||||
(req.method === "POST" && req.headers.origin !== origin) ||
|
||||
(req.headers["sec-fetch-site"] !== undefined &&
|
||||
req.headers["sec-fetch-site"] !== "same-origin")
|
||||
)
|
||||
return reject(res, 403, "forbidden");
|
||||
if (
|
||||
req.method === "POST" &&
|
||||
!/^application\/json(?:\s*;\s*charset=utf-8)?$/i.test(
|
||||
req.headers["content-type"] || "",
|
||||
)
|
||||
)
|
||||
return reject(res, 415, "invalid-request");
|
||||
// Fail closed if mounted after a body parser that bypassed our byte limit.
|
||||
if (req.body !== undefined) return reject(res, 503, "unavailable");
|
||||
const controller = new AbortController();
|
||||
res.locals.consentSignal = controller.signal;
|
||||
const timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(res, 408, "expired");
|
||||
req.destroy();
|
||||
}, 15000);
|
||||
const done = () => {
|
||||
clearTimeout(timer);
|
||||
controller.abort();
|
||||
};
|
||||
res.once("close", done);
|
||||
res.once("finish", done);
|
||||
next();
|
||||
});
|
||||
router.use(async (req, res, next) => {
|
||||
try {
|
||||
const actor = await current(req);
|
||||
if (res.destroyed || res.headersSent || res.locals.consentSignal.aborted)
|
||||
return;
|
||||
if (!actor) return reject(res, 401, "unauthorized");
|
||||
if (req.method === "POST") {
|
||||
const supplied = req.headers["x-review-csrf"];
|
||||
if (
|
||||
typeof supplied !== "string" ||
|
||||
!secret.test(supplied) ||
|
||||
!timingSafeEqual(Buffer.from(supplied, "hex"), csrf(actor))
|
||||
)
|
||||
return reject(res, 403, "forbidden");
|
||||
}
|
||||
const now = Date.now();
|
||||
for (const [id, rate] of rates) if (rate.until <= now) rates.delete(id);
|
||||
const rate = rates.get(actor.accountId) || {
|
||||
until: now + 60000,
|
||||
count: 0,
|
||||
};
|
||||
if (
|
||||
rate.count >= 60 ||
|
||||
(!rates.has(actor.accountId) && rates.size >= 1024)
|
||||
) {
|
||||
res.setHeader("Retry-After", "60");
|
||||
return reject(res, 429, "rate-limited");
|
||||
}
|
||||
rate.count++;
|
||||
rates.set(actor.accountId, rate);
|
||||
res.locals.consentActor = actor;
|
||||
next();
|
||||
} catch {
|
||||
reject(res, 503, "unavailable");
|
||||
}
|
||||
});
|
||||
router.get("/csrf", async (req, res) => {
|
||||
try {
|
||||
const fresh = await current(req);
|
||||
if (!fresh || fresh.accountId !== res.locals.consentActor.accountId)
|
||||
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") });
|
||||
} catch {
|
||||
reject(res, 503, "unavailable");
|
||||
}
|
||||
});
|
||||
router.use(express.json({ limit: 12288, strict: true, inflate: false }));
|
||||
router.post(["/preview", "/confirm"], async (req, res) => {
|
||||
const actor = res.locals.consentActor as Context;
|
||||
try {
|
||||
let result: unknown;
|
||||
if (req.url === "/preview") {
|
||||
if (
|
||||
!fields(req.body, ["repositoryId", "intent"]) ||
|
||||
typeof req.body.repositoryId !== "string" ||
|
||||
!/^[A-Za-z0-9_-]{3,128}$/.test(req.body.repositoryId) ||
|
||||
!fields(req.body.intent, [
|
||||
"contract",
|
||||
"clientId",
|
||||
"intentId",
|
||||
"token",
|
||||
"requestId",
|
||||
])
|
||||
)
|
||||
return reject(res, 422, "invalid-request");
|
||||
const input = req.body.intent;
|
||||
if (
|
||||
input.contract !== "4open.artifacts/1" ||
|
||||
![input.clientId, input.intentId, input.requestId].every(
|
||||
(v) => typeof v === "string" && opaque.test(v),
|
||||
) ||
|
||||
typeof input.token !== "string" ||
|
||||
!secret.test(input.token)
|
||||
)
|
||||
return reject(res, 422, "invalid-request");
|
||||
const preview = await backend.preview(
|
||||
actor,
|
||||
req.body.repositoryId,
|
||||
{
|
||||
contract: input.contract,
|
||||
clientId: input.clientId as string,
|
||||
intentId: input.intentId as string,
|
||||
requestId: input.requestId as string,
|
||||
token: input.token,
|
||||
},
|
||||
res.locals.consentSignal,
|
||||
);
|
||||
result = {
|
||||
ticket: preview.ticket,
|
||||
repositoryName: preview.repositoryName,
|
||||
policy: {
|
||||
version: preview.policy.version,
|
||||
access: preview.policy.access,
|
||||
retainUntil: preview.policy.retainUntil,
|
||||
},
|
||||
expiresAt: preview.expiresAt,
|
||||
};
|
||||
} else {
|
||||
if (
|
||||
!fields(req.body, [
|
||||
"ticket",
|
||||
"requestId",
|
||||
"acceptAccess",
|
||||
"acceptRetention",
|
||||
]) ||
|
||||
typeof req.body.ticket !== "string" ||
|
||||
req.body.ticket.length > 8192 ||
|
||||
typeof req.body.requestId !== "string" ||
|
||||
!opaque.test(req.body.requestId) ||
|
||||
req.body.acceptAccess !== true ||
|
||||
req.body.acceptRetention !== true
|
||||
)
|
||||
return reject(res, 422, "invalid-request");
|
||||
const saved = await backend.confirm(actor, req.body.ticket, {
|
||||
requestId: req.body.requestId,
|
||||
acceptAccess: true,
|
||||
acceptRetention: true,
|
||||
});
|
||||
result = {
|
||||
requestId: saved.requestId,
|
||||
policy: {
|
||||
version: saved.policy.version,
|
||||
access: saved.policy.access,
|
||||
retainUntil: saved.policy.retainUntil,
|
||||
},
|
||||
confirmedAt: saved.confirmedAt,
|
||||
};
|
||||
}
|
||||
const fresh = await current(req);
|
||||
if (
|
||||
!fresh ||
|
||||
fresh.accountId !== actor.accountId ||
|
||||
fresh.sessionId !== actor.sessionId
|
||||
)
|
||||
return reject(res, 401, "unauthorized");
|
||||
if (!res.headersSent && !res.destroyed) res.json(result);
|
||||
} catch (error) {
|
||||
const kind = (error as { kind?: string })?.kind;
|
||||
const status =
|
||||
kind === "invalid" || kind === "protocol"
|
||||
? 422
|
||||
: kind === "forbidden" || kind === "rejected"
|
||||
? 403
|
||||
: kind === "expired"
|
||||
? 410
|
||||
: kind === "conflict"
|
||||
? 409
|
||||
: 503;
|
||||
reject(
|
||||
res,
|
||||
status,
|
||||
status === 503
|
||||
? "unavailable"
|
||||
: status === 422
|
||||
? "invalid-request"
|
||||
: kind!,
|
||||
);
|
||||
}
|
||||
});
|
||||
router.use(((error, _req, res, _next) =>
|
||||
reject(
|
||||
res,
|
||||
error?.status === 413 ? 413 : 400,
|
||||
"invalid-request",
|
||||
)) as express.ErrorRequestHandler);
|
||||
return router;
|
||||
}
|
||||
Reference in new issue
Block a user