chore: import upstream snapshot with attribution
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,145 @@
|
||||
import type { Logger } from '@sim/logger'
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { safeCompare } from '@sim/security/compare'
|
||||
import { sha256Hex } from '@sim/security/hash'
|
||||
import { NextResponse } from 'next/server'
|
||||
import type { AuthContext, EventFilterContext } from '@/lib/webhooks/providers/types'
|
||||
|
||||
const logger = createLogger('WebhookProviderAuth')
|
||||
|
||||
/**
|
||||
* Deterministic JSON serialization with object keys sorted, so structurally
|
||||
* identical payloads produce identical output regardless of key order.
|
||||
*/
|
||||
function stableSerialize(value: unknown): string {
|
||||
if (Array.isArray(value)) {
|
||||
return `[${value.map((item) => stableSerialize(item)).join(',')}]`
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.entries(value as Record<string, unknown>)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([key, nested]) => `${JSON.stringify(key)}:${stableSerialize(nested)}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback idempotency fingerprint for payloads with no stable delivery id
|
||||
* or content timestamp to key on. A provider retry resends identical bytes,
|
||||
* so this hash is stable across retries of the same delivery while still
|
||||
* differentiating distinct events.
|
||||
*/
|
||||
export function buildFallbackDeliveryFingerprint(body: unknown): string {
|
||||
return sha256Hex(stableSerialize(body))
|
||||
}
|
||||
|
||||
interface HmacVerifierOptions {
|
||||
configKey: string
|
||||
headerName: string
|
||||
validateFn: (secret: string, signature: string, rawBody: string) => boolean | Promise<boolean>
|
||||
providerLabel: string
|
||||
/**
|
||||
* When true, reject (401) if no secret is configured instead of skipping
|
||||
* verification. Use for providers where the secret is always present (e.g.
|
||||
* auto-registered webhooks) so a missing secret fails closed.
|
||||
*/
|
||||
requireSecret?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Factory that creates a `verifyAuth` implementation for HMAC-signature-based providers.
|
||||
* Covers the common pattern: get secret → check header → validate signature → return 401 or null.
|
||||
*/
|
||||
export function createHmacVerifier({
|
||||
configKey,
|
||||
headerName,
|
||||
validateFn,
|
||||
providerLabel,
|
||||
requireSecret = false,
|
||||
}: HmacVerifierOptions) {
|
||||
return async ({
|
||||
request,
|
||||
rawBody,
|
||||
requestId,
|
||||
providerConfig,
|
||||
}: AuthContext): Promise<NextResponse | null> => {
|
||||
const secret = providerConfig[configKey] as string | undefined
|
||||
if (!secret) {
|
||||
if (requireSecret) {
|
||||
logger.warn(`[${requestId}] ${providerLabel} webhook secret not configured`)
|
||||
return new NextResponse(`Unauthorized - Missing ${providerLabel} webhook secret`, {
|
||||
status: 401,
|
||||
})
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
const signature = request.headers.get(headerName)
|
||||
if (!signature) {
|
||||
logger.warn(`[${requestId}] ${providerLabel} webhook missing signature header`)
|
||||
return new NextResponse(`Unauthorized - Missing ${providerLabel} signature`, { status: 401 })
|
||||
}
|
||||
|
||||
const isValid = await validateFn(secret, signature, rawBody)
|
||||
if (!isValid) {
|
||||
logger.warn(`[${requestId}] ${providerLabel} signature verification failed`, {
|
||||
signatureLength: signature.length,
|
||||
secretLength: secret.length,
|
||||
})
|
||||
return new NextResponse(`Unauthorized - Invalid ${providerLabel} signature`, { status: 401 })
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a bearer token or custom header token using timing-safe comparison.
|
||||
* Used by generic webhooks, Google Forms, and the default handler.
|
||||
*/
|
||||
export function verifyTokenAuth(
|
||||
request: Request,
|
||||
expectedToken: string,
|
||||
secretHeaderName?: string
|
||||
): boolean {
|
||||
if (secretHeaderName) {
|
||||
const headerValue = request.headers.get(secretHeaderName.toLowerCase())
|
||||
return !!headerValue && safeCompare(headerValue, expectedToken)
|
||||
}
|
||||
|
||||
const authHeader = request.headers.get('authorization')
|
||||
if (authHeader?.toLowerCase().startsWith('bearer ')) {
|
||||
const token = authHeader.substring(7)
|
||||
return safeCompare(token, expectedToken)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip events whose `body.type` is not in the `providerConfig.eventTypes` allowlist.
|
||||
* Shared by providers that use a simple event-type filter (Stripe, Grain, etc.).
|
||||
*/
|
||||
export function skipByEventTypes(
|
||||
{ webhook, body, requestId, providerConfig }: EventFilterContext,
|
||||
providerLabel: string,
|
||||
eventLogger: Logger
|
||||
): boolean {
|
||||
const eventTypes = providerConfig.eventTypes
|
||||
if (!eventTypes || !Array.isArray(eventTypes) || eventTypes.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
const eventType = (body as Record<string, unknown>)?.type as string | undefined
|
||||
if (eventType && !eventTypes.includes(eventType)) {
|
||||
eventLogger.info(
|
||||
`[${requestId}] ${providerLabel} event type '${eventType}' not in allowed list for webhook ${webhook.id as string}, skipping`
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
Reference in New Issue
Block a user