142 lines · 5.2 KB
import { env } from "cloudflare:workers";
import { recordWebhookDelivery } from "../webhook-log";
import { repoDb } from "../scoped-db";
import { decryptSecret, encryptSecret, hmacSha256Hex, randomId } from "../crypto";
import { badRequest } from "../errors";
import { assertOutboundUrl, isAllowedLoopback, isSuccess, outboundFetch } from "../integrations/outbound";
import { assertResolvesPublic } from "../net/resolve-guard";
import type { WebhookEvent } from "~/lib/webhook-events";

/** Envelope marker for secrets encrypted with the SECRETS_KEY AES-GCM wrapper; anything else is a legacy plaintext row. */
const SEALED_PREFIX = "enc:";
const WEBHOOK_SEAL = { purpose: "webhook-secret" };
const MAX_STORED_REQUEST = 64 * 1024;
export const MIN_WEBHOOK_SECRET = 8;
export const MAX_WEBHOOK_SECRET = 200;

export type LegacyWebhook = { id: string; repo_id: string; url: string; secret: string | null };

function secretsKey(): string {
  if (!env.SECRETS_KEY) throw badRequest("SECRETS_KEY is not configured on this instance");
  return env.SECRETS_KEY;
}

/** Validates a legacy repo webhook target: public https (or http when the operator opted in), no userinfo or IP literals. */
export function assertWebhookUrl(raw: string): URL {
  return assertOutboundUrl(raw, undefined, true);
}

export async function assertWebhookTarget(raw: string): Promise<URL> {
  const url = assertWebhookUrl(raw);
  if (!isAllowedLoopback(url)) await assertResolvesPublic(url);
  return url;
}

export async function sealWebhookSecret(secret: string | null): Promise<string | null> {
  if (secret === null) return null;
  const sealed = await encryptSecret(secretsKey(), secret, WEBHOOK_SEAL);
  return `${SEALED_PREFIX}${sealed.iv}:${sealed.ciphertext}`;
}