11import { type IncomingMessage , type ServerResponse } from "node:http" ;
2- import { describe , expect , it , beforeEach , afterEach } from "vitest" ;
2+ import { describe , expect , it , beforeEach , afterEach , vi } from "vitest" ;
33import { setMattermostRuntime } from "../runtime.js" ;
44import { resolveMattermostAccount } from "./accounts.js" ;
55import type { MattermostClient } from "./client.js" ;
@@ -109,6 +109,53 @@ describe("generateInteractionToken / verifyInteractionToken", () => {
109109 expect ( verifyInteractionToken ( reorderedContext , token ) ) . toBe ( true ) ;
110110 } ) ;
111111
112+ it ( "verifies nested context regardless of nested key order" , ( ) => {
113+ const originalContext = {
114+ action_id : "nested" ,
115+ payload : {
116+ model : "gpt-5" ,
117+ meta : {
118+ provider : "openai" ,
119+ page : 2 ,
120+ } ,
121+ } ,
122+ } ;
123+ const token = generateInteractionToken ( originalContext ) ;
124+
125+ const reorderedContext = {
126+ payload : {
127+ meta : {
128+ page : 2 ,
129+ provider : "openai" ,
130+ } ,
131+ model : "gpt-5" ,
132+ } ,
133+ action_id : "nested" ,
134+ } ;
135+
136+ expect ( verifyInteractionToken ( reorderedContext , token ) ) . toBe ( true ) ;
137+ } ) ;
138+
139+ it ( "rejects nested context tampering" , ( ) => {
140+ const originalContext = {
141+ action_id : "nested" ,
142+ payload : {
143+ provider : "openai" ,
144+ model : "gpt-5" ,
145+ } ,
146+ } ;
147+ const token = generateInteractionToken ( originalContext ) ;
148+ const tamperedContext = {
149+ action_id : "nested" ,
150+ payload : {
151+ provider : "anthropic" ,
152+ model : "gpt-5" ,
153+ } ,
154+ } ;
155+
156+ expect ( verifyInteractionToken ( tamperedContext , token ) ) . toBe ( false ) ;
157+ } ) ;
158+
112159 it ( "scopes tokens per account when account secrets differ" , ( ) => {
113160 setInteractionSecret ( "acct-a" , "bot-token-a" ) ;
114161 setInteractionSecret ( "acct-b" , "bot-token-b" ) ;
@@ -400,12 +447,14 @@ describe("createMattermostInteractionHandler", () => {
400447 method ?: string ;
401448 body ?: unknown ;
402449 remoteAddress ?: string ;
450+ headers ?: Record < string , string > ;
403451 } ) : IncomingMessage {
404452 const body = params . body === undefined ? "" : JSON . stringify ( params . body ) ;
405453 const listeners = new Map < string , Array < ( ...args : unknown [ ] ) => void > > ( ) ;
406454
407455 const req = {
408456 method : params . method ?? "POST" ,
457+ headers : params . headers ?? { } ,
409458 socket : { remoteAddress : params . remoteAddress ?? "203.0.113.10" } ,
410459 on ( event : string , handler : ( ...args : unknown [ ] ) => void ) {
411460 const existing = listeners . get ( event ) ?? [ ] ;
@@ -447,7 +496,7 @@ describe("createMattermostInteractionHandler", () => {
447496 return res as unknown as ServerResponse & { headers : Record < string , string > ; body : string } ;
448497 }
449498
450- it ( "accepts non-localhost requests when the interaction token is valid " , async ( ) => {
499+ it ( "accepts callback requests from an allowlisted source IP " , async ( ) => {
451500 const context = { action_id : "approve" , __openclaw_channel_id : "chan-1" } ;
452501 const token = generateInteractionToken ( context , "acct" ) ;
453502 const requestLog : Array < { path : string ; method ?: string } > = [ ] ;
@@ -469,6 +518,7 @@ describe("createMattermostInteractionHandler", () => {
469518 } as unknown as MattermostClient ,
470519 botUserId : "bot" ,
471520 accountId : "acct" ,
521+ allowedSourceIps : [ "198.51.100.8" ] ,
472522 } ) ;
473523
474524 const req = createReq ( {
@@ -493,6 +543,80 @@ describe("createMattermostInteractionHandler", () => {
493543 ] ) ;
494544 } ) ;
495545
546+ it ( "accepts forwarded Mattermost source IPs from a trusted proxy" , async ( ) => {
547+ const context = { action_id : "approve" , __openclaw_channel_id : "chan-1" } ;
548+ const token = generateInteractionToken ( context , "acct" ) ;
549+ const handler = createMattermostInteractionHandler ( {
550+ client : {
551+ request : async ( _path : string , init ?: { method ?: string } ) => {
552+ if ( init ?. method === "PUT" ) {
553+ return { id : "post-1" } ;
554+ }
555+ return {
556+ channel_id : "chan-1" ,
557+ message : "Choose" ,
558+ props : {
559+ attachments : [ { actions : [ { id : "approve" , name : "Approve" } ] } ] ,
560+ } ,
561+ } ;
562+ } ,
563+ } as unknown as MattermostClient ,
564+ botUserId : "bot" ,
565+ accountId : "acct" ,
566+ allowedSourceIps : [ "198.51.100.8" ] ,
567+ trustedProxies : [ "127.0.0.1" ] ,
568+ } ) ;
569+
570+ const req = createReq ( {
571+ remoteAddress : "127.0.0.1" ,
572+ headers : { "x-forwarded-for" : "198.51.100.8" } ,
573+ body : {
574+ user_id : "user-1" ,
575+ user_name : "alice" ,
576+ channel_id : "chan-1" ,
577+ post_id : "post-1" ,
578+ context : { ...context , _token : token } ,
579+ } ,
580+ } ) ;
581+ const res = createRes ( ) ;
582+
583+ await handler ( req , res ) ;
584+
585+ expect ( res . statusCode ) . toBe ( 200 ) ;
586+ expect ( res . body ) . toBe ( "{}" ) ;
587+ } ) ;
588+
589+ it ( "rejects callback requests from non-allowlisted source IPs" , async ( ) => {
590+ const context = { action_id : "approve" , __openclaw_channel_id : "chan-1" } ;
591+ const token = generateInteractionToken ( context , "acct" ) ;
592+ const handler = createMattermostInteractionHandler ( {
593+ client : {
594+ request : async ( ) => {
595+ throw new Error ( "should not fetch post for rejected origins" ) ;
596+ } ,
597+ } as unknown as MattermostClient ,
598+ botUserId : "bot" ,
599+ accountId : "acct" ,
600+ allowedSourceIps : [ "127.0.0.1" ] ,
601+ } ) ;
602+
603+ const req = createReq ( {
604+ remoteAddress : "198.51.100.8" ,
605+ body : {
606+ user_id : "user-1" ,
607+ channel_id : "chan-1" ,
608+ post_id : "post-1" ,
609+ context : { ...context , _token : token } ,
610+ } ,
611+ } ) ;
612+ const res = createRes ( ) ;
613+
614+ await handler ( req , res ) ;
615+
616+ expect ( res . statusCode ) . toBe ( 403 ) ;
617+ expect ( res . body ) . toContain ( "Forbidden origin" ) ;
618+ } ) ;
619+
496620 it ( "rejects requests with an invalid interaction token" , async ( ) => {
497621 const handler = createMattermostInteractionHandler ( {
498622 client : {
@@ -610,4 +734,62 @@ describe("createMattermostInteractionHandler", () => {
610734 expect ( res . statusCode ) . toBe ( 403 ) ;
611735 expect ( res . body ) . toContain ( "Unknown action" ) ;
612736 } ) ;
737+
738+ it ( "lets a custom interaction handler short-circuit generic completion updates" , async ( ) => {
739+ const context = { action_id : "mdlprov" , __openclaw_channel_id : "chan-1" } ;
740+ const token = generateInteractionToken ( context , "acct" ) ;
741+ const requestLog : Array < { path : string ; method ?: string } > = [ ] ;
742+ const handleInteraction = vi . fn ( ) . mockResolvedValue ( {
743+ ephemeral_text : "Only the original requester can use this picker." ,
744+ } ) ;
745+ const dispatchButtonClick = vi . fn ( ) ;
746+ const handler = createMattermostInteractionHandler ( {
747+ client : {
748+ request : async ( path : string , init ?: { method ?: string } ) => {
749+ requestLog . push ( { path, method : init ?. method } ) ;
750+ return {
751+ channel_id : "chan-1" ,
752+ message : "Choose" ,
753+ props : {
754+ attachments : [ { actions : [ { id : "mdlprov" , name : "Browse providers" } ] } ] ,
755+ } ,
756+ } ;
757+ } ,
758+ } as unknown as MattermostClient ,
759+ botUserId : "bot" ,
760+ accountId : "acct" ,
761+ handleInteraction,
762+ dispatchButtonClick,
763+ } ) ;
764+
765+ const req = createReq ( {
766+ body : {
767+ user_id : "user-2" ,
768+ user_name : "alice" ,
769+ channel_id : "chan-1" ,
770+ post_id : "post-1" ,
771+ context : { ...context , _token : token } ,
772+ } ,
773+ } ) ;
774+ const res = createRes ( ) ;
775+
776+ await handler ( req , res ) ;
777+
778+ expect ( res . statusCode ) . toBe ( 200 ) ;
779+ expect ( res . body ) . toBe (
780+ JSON . stringify ( {
781+ ephemeral_text : "Only the original requester can use this picker." ,
782+ } ) ,
783+ ) ;
784+ expect ( requestLog ) . toEqual ( [ { path : "/posts/post-1" , method : undefined } ] ) ;
785+ expect ( handleInteraction ) . toHaveBeenCalledWith (
786+ expect . objectContaining ( {
787+ actionId : "mdlprov" ,
788+ actionName : "Browse providers" ,
789+ originalMessage : "Choose" ,
790+ userName : "alice" ,
791+ } ) ,
792+ ) ;
793+ expect ( dispatchButtonClick ) . not . toHaveBeenCalled ( ) ;
794+ } ) ;
613795} ) ;
0 commit comments