@@ -21,7 +21,11 @@ import { ConditionalCheckFailedException, DynamoDBClient } from '@aws-sdk/client
2121import { InvokeCommand , LambdaClient } from '@aws-sdk/client-lambda' ;
2222import { DeleteCommand , DynamoDBDocumentClient , PutCommand } from '@aws-sdk/lib-dynamodb' ;
2323import type { APIGatewayProxyEvent , APIGatewayProxyResult } from 'aws-lambda' ;
24- import { isWebhookTimestampFresh , verifyLinearRequest } from './shared/linear-verify' ;
24+ import {
25+ isWebhookTimestampFresh ,
26+ verifyLinearRequest ,
27+ verifyLinearRequestForWorkspace ,
28+ } from './shared/linear-verify' ;
2529import { logger } from './shared/logger' ;
2630
2731const ddb = DynamoDBDocumentClient . from ( new DynamoDBClient ( { } ) ) ;
@@ -30,6 +34,10 @@ const lambdaClient = new LambdaClient({});
3034const WEBHOOK_SECRET_ARN = process . env . LINEAR_WEBHOOK_SECRET_ARN ! ;
3135const DEDUP_TABLE_NAME = process . env . LINEAR_WEBHOOK_DEDUP_TABLE_NAME ! ;
3236const PROCESSOR_FUNCTION_NAME = process . env . LINEAR_WEBHOOK_PROCESSOR_FUNCTION_NAME ! ;
37+ /** Optional. When unset, the per-workspace signing-secret path is skipped
38+ * and only the stack-wide secret is consulted (back-compat for installs
39+ * predating per-workspace secrets). */
40+ const WORKSPACE_REGISTRY_TABLE = process . env . LINEAR_WORKSPACE_REGISTRY_TABLE_NAME ;
3341
3442/**
3543 * Dedup window (seconds). Must exceed Linear's full retry horizon: first
@@ -79,11 +87,11 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
7987 return jsonResponse ( 401 , { error : 'Missing signature' } ) ;
8088 }
8189
82- if ( ! await verifyLinearRequest ( WEBHOOK_SECRET_ARN , signature , event . body ) ) {
83- logger . warn ( 'Invalid Linear webhook signature' ) ;
84- return jsonResponse ( 401 , { error : 'Invalid signature' } ) ;
85- }
86-
90+ // Parse body ONCE — we peek at the orgId before signature verification
91+ // so we can pick the right per-workspace signing secret. The orgId is
92+ // untrusted at this point; it only selects WHICH secret to verify
93+ // against. An attacker can claim any orgId but still needs the
94+ // matching signing secret to forge a valid signature.
8795 let payload : LinearWebhookEnvelope ;
8896 try {
8997 payload = JSON . parse ( event . body ) as LinearWebhookEnvelope ;
@@ -94,6 +102,57 @@ export async function handler(event: APIGatewayProxyEvent): Promise<APIGatewayPr
94102 return jsonResponse ( 400 , { error : 'Invalid JSON' } ) ;
95103 }
96104
105+ // Try the per-workspace secret first. Falls through to the stack-wide
106+ // path if (a) registry table not configured, (b) no orgId in body,
107+ // (c) workspace not in registry, or (d) workspace's stored secret
108+ // lacks `webhook_signing_secret`. Per-workspace MISMATCH is fatal —
109+ // do NOT fall back, that would let an attacker bypass per-workspace
110+ // signatures by also matching the stack-wide one. REVOKED is also
111+ // fatal: a workspace that has been deactivated must not be able to
112+ // ride the stack-wide fallback (which `setup` mirrored from the
113+ // first workspace's signing secret) back into a verified state.
114+ let verified = false ;
115+ if ( WORKSPACE_REGISTRY_TABLE && payload . organizationId ) {
116+ const result = await verifyLinearRequestForWorkspace (
117+ WORKSPACE_REGISTRY_TABLE ,
118+ payload . organizationId ,
119+ signature ,
120+ event . body ,
121+ ) ;
122+ if ( result === 'verified' ) {
123+ verified = true ;
124+ } else if ( result === 'mismatch' ) {
125+ logger . warn ( 'Linear webhook signature mismatch against per-workspace secret' , {
126+ linear_workspace_id : payload . organizationId ,
127+ } ) ;
128+ return jsonResponse ( 401 , { error : 'Invalid signature' } ) ;
129+ } else if ( result === 'revoked' ) {
130+ logger . warn ( 'Linear webhook from revoked workspace — rejecting without stack-wide fallback' , {
131+ linear_workspace_id : payload . organizationId ,
132+ } ) ;
133+ return jsonResponse ( 401 , { error : 'Workspace not active' } ) ;
134+ }
135+ // 'no-per-workspace-secret' falls through to the stack-wide path
136+ // below — back-compat for installs predating per-workspace secrets.
137+ }
138+
139+ if ( ! verified ) {
140+ if ( ! await verifyLinearRequest ( WEBHOOK_SECRET_ARN , signature , event . body ) ) {
141+ logger . warn ( 'Invalid Linear webhook signature' , {
142+ linear_workspace_id : payload . organizationId ,
143+ } ) ;
144+ return jsonResponse ( 401 , { error : 'Invalid signature' } ) ;
145+ }
146+ // Stack-wide fallback succeeded. Log positively so operators
147+ // diagnosing a per-workspace verification regression have a
148+ // breadcrumb that says "this workspace is verifying via the
149+ // back-compat path" rather than its own per-workspace secret.
150+ logger . info ( 'Linear webhook verified via stack-wide fallback secret' , {
151+ linear_workspace_id : payload . organizationId ,
152+ per_workspace_registry_configured : Boolean ( WORKSPACE_REGISTRY_TABLE ) ,
153+ } ) ;
154+ }
155+
97156 if ( ! isWebhookTimestampFresh ( payload . webhookTimestamp ) ) {
98157 logger . warn ( 'Linear webhook timestamp outside replay window' , {
99158 webhook_timestamp : payload . webhookTimestamp ,
0 commit comments