@@ -8,6 +8,7 @@ import type { Id } from './_generated/dataModel.js';
88import { httpAction , type ActionCtx } from './_generated/server.js' ;
99import { AnalyticsEvents } from './analyticsEvents.js' ;
1010import { instances } from './apiHelpers.js' ;
11+ import { assertSafeServerUrl } from './urlSafety.js' ;
1112
1213const usageActions = api . usage ;
1314const instanceActions = instances . actions ;
@@ -19,6 +20,7 @@ const http = httpRouter();
1920const corsAllowedMethods = 'GET, POST, OPTIONS' ;
2021const corsMaxAgeSeconds = 60 * 60 * 24 ;
2122const defaultAllowedHeaders = 'Content-Type, Authorization, X-Requested-With' ;
23+ const svixMaxSkewSeconds = 5 * 60 ;
2224
2325const buildAllowedOrigins = ( ) : Set < string > => {
2426 const origins = ( process . env . CLIENT_ORIGIN ?? '' )
@@ -338,7 +340,7 @@ const chatStream = httpAction(async (ctx, request) => {
338340
339341 const serverUrl = await ensureServerUrl ( ctx , instance , sendEvent ) ;
340342
341- const response = await fetch ( ` ${ serverUrl } /question/stream` , {
343+ const response = await fetch ( new URL ( ' /question/stream' , serverUrl ) , {
342344 method : 'POST' ,
343345 headers : {
344346 'Content-Type' : 'application/json'
@@ -563,7 +565,7 @@ const chatStream = httpAction(async (ctx, request) => {
563565 const response = new Response ( stream , {
564566 headers : {
565567 'Content-Type' : 'text/event-stream' ,
566- 'Cache-Control' : 'no-cache ' ,
568+ 'Cache-Control' : 'no-store ' ,
567569 Connection : 'keep-alive'
568570 }
569571 } ) ;
@@ -590,6 +592,16 @@ const clerkWebhook = httpAction(async (ctx, request) => {
590592 return withCors ( request , response ) ;
591593 }
592594
595+ if ( ! isSvixTimestampFresh ( headers [ 'svix-timestamp' ] ) ) {
596+ await ctx . scheduler . runAfter ( 0 , internal . analytics . trackEvent , {
597+ distinctId : 'webhook_system' ,
598+ event : AnalyticsEvents . WEBHOOK_VERIFICATION_FAILED ,
599+ properties : { webhookType : 'clerk' , reason : 'stale_timestamp' }
600+ } ) ;
601+ const response = jsonResponse ( { error : 'Stale webhook timestamp' } , { status : 400 } ) ;
602+ return withCors ( request , response ) ;
603+ }
604+
593605 const verifiedPayload = await verifySvixSignature ( payload , headers , secret ) ;
594606 if ( ! verifiedPayload ) {
595607 await ctx . scheduler . runAfter ( 0 , internal . analytics . trackEvent , {
@@ -667,6 +679,15 @@ const daytonaWebhook = httpAction(async (ctx, request) => {
667679 return jsonResponse ( { error : 'Missing Svix headers' } , { status : 400 } ) ;
668680 }
669681
682+ if ( ! isSvixTimestampFresh ( headers [ 'svix-timestamp' ] ) ) {
683+ await ctx . scheduler . runAfter ( 0 , internal . analytics . trackEvent , {
684+ distinctId : 'webhook_system' ,
685+ event : AnalyticsEvents . WEBHOOK_VERIFICATION_FAILED ,
686+ properties : { webhookType : 'daytona' , reason : 'stale_timestamp' }
687+ } ) ;
688+ return jsonResponse ( { error : 'Stale webhook timestamp' } , { status : 400 } ) ;
689+ }
690+
670691 const verifiedPayload = await verifySvixSignature ( payload , headers , secret ) ;
671692 if ( ! verifiedPayload ) {
672693 await ctx . scheduler . runAfter ( 0 , internal . analytics . trackEvent , {
@@ -720,6 +741,30 @@ function getSvixHeaders(request: Request): SvixHeaders | null {
720741 } ;
721742}
722743
744+ const parseSvixTimestampSeconds = ( raw : string ) => {
745+ const ts = Number ( raw ) ;
746+ if ( ! Number . isFinite ( ts ) ) return null ;
747+ return Math . floor ( ts > 1e12 ? ts / 1000 : ts ) ;
748+ } ;
749+
750+ const isSvixTimestampFresh = ( raw : string , maxSkewSeconds = svixMaxSkewSeconds ) => {
751+ const ts = parseSvixTimestampSeconds ( raw ) ;
752+ if ( ! ts ) return false ;
753+ const now = Math . floor ( Date . now ( ) / 1000 ) ;
754+ return Math . abs ( now - ts ) <= maxSkewSeconds ;
755+ } ;
756+
757+ const timingSafeEqual = ( a : string , b : string ) => {
758+ const len = Math . max ( a . length , b . length ) ;
759+ let result = 0 ;
760+ for ( let i = 0 ; i < len ; i ++ ) {
761+ const ca = a . charCodeAt ( i ) || 0 ;
762+ const cb = b . charCodeAt ( i ) || 0 ;
763+ result |= ca ^ cb ;
764+ }
765+ return result === 0 && a . length === b . length ;
766+ } ;
767+
723768async function verifySvixSignature (
724769 payload : string ,
725770 headers : SvixHeaders ,
@@ -760,8 +805,8 @@ async function verifySvixSignature(
760805 . filter ( ( value ) : value is string => Boolean ( value ) ) ;
761806
762807 const normalizedSignature = signatureBase64 . replace ( / = + $ / , '' ) ;
763- const matches = candidates . some (
764- ( candidate ) => candidate . replace ( / = + $ / , '' ) === normalizedSignature
808+ const matches = candidates . some ( ( candidate ) =>
809+ timingSafeEqual ( candidate . replace ( / = + $ / , '' ) , normalizedSignature )
765810 ) ;
766811 if ( ! matches ) {
767812 return null ;
@@ -854,7 +899,7 @@ async function ensureServerUrl(
854899
855900 if ( instance . state === 'running' && instance . serverUrl ) {
856901 sendEvent ( { type : 'status' , status : 'ready' } ) ;
857- return instance . serverUrl ;
902+ return assertSafeServerUrl ( instance . serverUrl ) ;
858903 }
859904
860905 if ( ! instance . sandboxId ) {
@@ -872,5 +917,5 @@ async function ensureServerUrl(
872917 }
873918
874919 sendEvent ( { type : 'status' , status : 'ready' } ) ;
875- return serverUrl ;
920+ return assertSafeServerUrl ( serverUrl ) ;
876921}
0 commit comments