@@ -40,25 +40,6 @@ describe("handleControlUiHttpRequest", () => {
4040 expect ( params . end ) . toHaveBeenCalledWith ( "Not Found" ) ;
4141 }
4242
43- function expectUnhandledRoutes ( params : {
44- urls : string [ ] ;
45- method : "GET" | "POST" ;
46- rootPath : string ;
47- basePath ?: string ;
48- expectationLabel : string ;
49- } ) {
50- for ( const url of params . urls ) {
51- const { handled, end } = runControlUiRequest ( {
52- url,
53- method : params . method ,
54- rootPath : params . rootPath ,
55- ...( params . basePath ? { basePath : params . basePath } : { } ) ,
56- } ) ;
57- expect ( handled , `${ params . expectationLabel } : ${ url } ` ) . toBe ( false ) ;
58- expect ( end , `${ params . expectationLabel } : ${ url } ` ) . not . toHaveBeenCalled ( ) ;
59- }
60- }
61-
6243 function runControlUiRequest ( params : {
6344 url : string ;
6445 method : "GET" | "HEAD" | "POST" ;
@@ -104,6 +85,13 @@ describe("handleControlUiHttpRequest", () => {
10485 return { assetsDir, filePath } ;
10586 }
10687
88+ async function createHardlinkedAssetFile ( rootPath : string ) {
89+ const { filePath } = await writeAssetFile ( rootPath , "app.js" , "console.log('hi');" ) ;
90+ const hardlinkPath = path . join ( path . dirname ( filePath ) , "app.hl.js" ) ;
91+ await fs . link ( filePath , hardlinkPath ) ;
92+ return hardlinkPath ;
93+ }
94+
10795 async function withBasePathRootFixture < T > ( params : {
10896 siblingDir : string ;
10997 fn : ( paths : { root : string ; sibling : string } ) => Promise < T > ;
@@ -166,80 +154,53 @@ describe("handleControlUiHttpRequest", () => {
166154 } ) ;
167155 } ) ;
168156
169- it . each ( [
170- {
171- name : "at root" ,
172- url : CONTROL_UI_BOOTSTRAP_CONFIG_PATH ,
173- expectedBasePath : "" ,
174- assistantName : "</script><script>alert(1)//" ,
175- assistantAvatar : "</script>.png" ,
176- expectedAvatarUrl : "/avatar/main" ,
177- } ,
178- {
179- name : "under basePath" ,
180- url : `/openclaw${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } ` ,
181- basePath : "/openclaw" ,
182- expectedBasePath : "/openclaw" ,
183- assistantName : "Ops" ,
184- assistantAvatar : "ops.png" ,
185- expectedAvatarUrl : "/openclaw/avatar/main" ,
186- } ,
187- ] ) ( "serves bootstrap config JSON $name" , async ( testCase ) => {
157+ it ( "serves bootstrap config JSON" , async ( ) => {
188158 await withControlUiRoot ( {
189159 fn : async ( tmp ) => {
190160 const { res, end } = makeMockHttpResponse ( ) ;
191161 const handled = handleControlUiHttpRequest (
192- { url : testCase . url , method : "GET" } as IncomingMessage ,
162+ { url : CONTROL_UI_BOOTSTRAP_CONFIG_PATH , method : "GET" } as IncomingMessage ,
193163 res ,
194164 {
195- ...( testCase . basePath ? { basePath : testCase . basePath } : { } ) ,
196165 root : { kind : "resolved" , path : tmp } ,
197166 config : {
198167 agents : { defaults : { workspace : tmp } } ,
199- ui : {
200- assistant : {
201- name : testCase . assistantName ,
202- avatar : testCase . assistantAvatar ,
203- } ,
204- } ,
168+ ui : { assistant : { name : "</script><script>alert(1)//" , avatar : "</script>.png" } } ,
205169 } ,
206170 } ,
207171 ) ;
208172 expect ( handled ) . toBe ( true ) ;
209173 const parsed = parseBootstrapPayload ( end ) ;
210- expect ( parsed . basePath ) . toBe ( testCase . expectedBasePath ) ;
211- expect ( parsed . assistantName ) . toBe ( testCase . assistantName ) ;
212- expect ( parsed . assistantAvatar ) . toBe ( testCase . expectedAvatarUrl ) ;
174+ expect ( parsed . basePath ) . toBe ( "" ) ;
175+ expect ( parsed . assistantName ) . toBe ( "</script><script>alert(1)//" ) ;
176+ expect ( parsed . assistantAvatar ) . toBe ( "/avatar/main" ) ;
213177 expect ( parsed . assistantAgentId ) . toBe ( "main" ) ;
214178 } ,
215179 } ) ;
216180 } ) ;
217181
218- it . each ( [
219- {
220- name : "at root" ,
221- url : CONTROL_UI_BOOTSTRAP_CONFIG_PATH ,
222- } ,
223- {
224- name : "under basePath" ,
225- url : `/openclaw${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } ` ,
226- basePath : "/openclaw" ,
227- } ,
228- ] ) ( "serves bootstrap config HEAD $name without writing a body" , async ( testCase ) => {
182+ it ( "serves bootstrap config JSON under basePath" , async ( ) => {
229183 await withControlUiRoot ( {
230184 fn : async ( tmp ) => {
231185 const { res, end } = makeMockHttpResponse ( ) ;
232186 const handled = handleControlUiHttpRequest (
233- { url : testCase . url , method : "HEAD " } as IncomingMessage ,
187+ { url : `/openclaw ${ CONTROL_UI_BOOTSTRAP_CONFIG_PATH } ` , method : "GET " } as IncomingMessage ,
234188 res ,
235189 {
236- ... ( testCase . basePath ? { basePath : testCase . basePath } : { } ) ,
190+ basePath : "/openclaw" ,
237191 root : { kind : "resolved" , path : tmp } ,
192+ config : {
193+ agents : { defaults : { workspace : tmp } } ,
194+ ui : { assistant : { name : "Ops" , avatar : "ops.png" } } ,
195+ } ,
238196 } ,
239197 ) ;
240198 expect ( handled ) . toBe ( true ) ;
241- expect ( res . statusCode ) . toBe ( 200 ) ;
242- expect ( end . mock . calls [ 0 ] ?. length ?? - 1 ) . toBe ( 0 ) ;
199+ const parsed = parseBootstrapPayload ( end ) ;
200+ expect ( parsed . basePath ) . toBe ( "/openclaw" ) ;
201+ expect ( parsed . assistantName ) . toBe ( "Ops" ) ;
202+ expect ( parsed . assistantAvatar ) . toBe ( "/openclaw/avatar/main" ) ;
203+ expect ( parsed . assistantAgentId ) . toBe ( "main" ) ;
243204 } ,
244205 } ) ;
245206 } ) ;
@@ -396,113 +357,132 @@ describe("handleControlUiHttpRequest", () => {
396357 } ) ;
397358 } ) ;
398359
399- it . each ( [
400- {
401- name : "rejects hardlinked asset files for custom/resolved roots" ,
402- rootKind : "resolved" as const ,
403- expectedStatus : 404 ,
404- expectedBody : "Not Found" ,
405- } ,
406- {
407- name : "serves hardlinked asset files for bundled roots" ,
408- rootKind : "bundled" as const ,
409- expectedStatus : 200 ,
410- expectedBody : "console.log('hi');" ,
411- } ,
412- ] ) ( "$name" , async ( testCase ) => {
360+ it ( "rejects hardlinked asset files for custom/resolved roots (security boundary)" , async ( ) => {
413361 await withControlUiRoot ( {
414362 fn : async ( tmp ) => {
415- const assetsDir = path . join ( tmp , "assets" ) ;
416- await fs . mkdir ( assetsDir , { recursive : true } ) ;
417- await fs . writeFile ( path . join ( assetsDir , "app.js" ) , "console.log('hi');" ) ;
418- await fs . link ( path . join ( assetsDir , "app.js" ) , path . join ( assetsDir , "app.hl.js" ) ) ;
363+ await createHardlinkedAssetFile ( tmp ) ;
419364
420365 const { res, end, handled } = runControlUiRequest ( {
421366 url : "/assets/app.hl.js" ,
422367 method : "GET" ,
423368 rootPath : tmp ,
424- rootKind : testCase . rootKind ,
425369 } ) ;
426370
427371 expect ( handled ) . toBe ( true ) ;
428- expect ( res . statusCode ) . toBe ( testCase . expectedStatus ) ;
429- expect ( String ( end . mock . calls [ 0 ] ?. [ 0 ] ?? "" ) ) . toBe ( testCase . expectedBody ) ;
372+ expect ( res . statusCode ) . toBe ( 404 ) ;
373+ expect ( end ) . toHaveBeenCalledWith ( "Not Found" ) ;
430374 } ,
431375 } ) ;
432376 } ) ;
433377
434- it ( "does not handle POST to root-mounted paths (plugin webhook passthrough )" , async ( ) => {
378+ it ( "serves hardlinked asset files for bundled roots (pnpm global install )" , async ( ) => {
435379 await withControlUiRoot ( {
436380 fn : async ( tmp ) => {
437- expectUnhandledRoutes ( {
438- urls : [ "/bluebubbles-webhook" , "/custom-webhook" , "/callback" ] ,
439- method : "POST" ,
381+ await createHardlinkedAssetFile ( tmp ) ;
382+
383+ const { res, end, handled } = runControlUiRequest ( {
384+ url : "/assets/app.hl.js" ,
385+ method : "GET" ,
440386 rootPath : tmp ,
441- expectationLabel : "POST should pass through to plugin handlers " ,
387+ rootKind : "bundled " ,
442388 } ) ;
389+
390+ expect ( handled ) . toBe ( true ) ;
391+ expect ( res . statusCode ) . toBe ( 200 ) ;
392+ expect ( String ( end . mock . calls [ 0 ] ?. [ 0 ] ?? "" ) ) . toBe ( "console.log('hi');" ) ;
393+ } ,
394+ } ) ;
395+ } ) ;
396+
397+ it ( "does not handle POST to root-mounted paths (plugin webhook passthrough)" , async ( ) => {
398+ await withControlUiRoot ( {
399+ fn : async ( tmp ) => {
400+ for ( const webhookPath of [ "/bluebubbles-webhook" , "/custom-webhook" , "/callback" ] ) {
401+ const { res } = makeMockHttpResponse ( ) ;
402+ const handled = handleControlUiHttpRequest (
403+ { url : webhookPath , method : "POST" } as IncomingMessage ,
404+ res ,
405+ { root : { kind : "resolved" , path : tmp } } ,
406+ ) ;
407+ expect ( handled , `POST to ${ webhookPath } should pass through to plugin handlers` ) . toBe (
408+ false ,
409+ ) ;
410+ }
443411 } ,
444412 } ) ;
445413 } ) ;
446414
447415 it ( "does not handle POST to paths outside basePath" , async ( ) => {
448416 await withControlUiRoot ( {
449417 fn : async ( tmp ) => {
450- expectUnhandledRoutes ( {
451- urls : [ "/bluebubbles-webhook" ] ,
452- method : "POST" ,
453- rootPath : tmp ,
454- basePath : "/openclaw" ,
455- expectationLabel : "POST outside basePath should pass through" ,
456- } ) ;
418+ const { res } = makeMockHttpResponse ( ) ;
419+ const handled = handleControlUiHttpRequest (
420+ { url : "/bluebubbles-webhook" , method : "POST" } as IncomingMessage ,
421+ res ,
422+ { basePath : "/openclaw" , root : { kind : "resolved" , path : tmp } } ,
423+ ) ;
424+ expect ( handled ) . toBe ( false ) ;
457425 } ,
458426 } ) ;
459427 } ) ;
460428
461- it . each ( [
462- {
463- name : "does not handle /api paths when basePath is empty" ,
464- urls : [ "/api" , "/api/sessions" , "/api/channels/nostr" ] ,
465- } ,
466- {
467- name : "does not handle /plugins paths when basePath is empty" ,
468- urls : [ "/plugins" , "/plugins/diffs/view/abc/def" ] ,
469- } ,
470- ] ) ( "$name" , async ( testCase ) => {
429+ it ( "does not handle /api paths when basePath is empty" , async ( ) => {
471430 await withControlUiRoot ( {
472431 fn : async ( tmp ) => {
473- expectUnhandledRoutes ( {
474- urls : testCase . urls ,
475- method : "GET" ,
476- rootPath : tmp ,
477- expectationLabel : "expected route to not be handled" ,
478- } ) ;
432+ for ( const apiPath of [ "/api" , "/api/sessions" , "/api/channels/nostr" ] ) {
433+ const { handled } = runControlUiRequest ( {
434+ url : apiPath ,
435+ method : "GET" ,
436+ rootPath : tmp ,
437+ } ) ;
438+ expect ( handled , `expected ${ apiPath } to not be handled` ) . toBe ( false ) ;
439+ }
440+ } ,
441+ } ) ;
442+ } ) ;
443+
444+ it ( "does not handle /plugins paths when basePath is empty" , async ( ) => {
445+ await withControlUiRoot ( {
446+ fn : async ( tmp ) => {
447+ for ( const pluginPath of [ "/plugins" , "/plugins/diffs/view/abc/def" ] ) {
448+ const { handled } = runControlUiRequest ( {
449+ url : pluginPath ,
450+ method : "GET" ,
451+ rootPath : tmp ,
452+ } ) ;
453+ expect ( handled , `expected ${ pluginPath } to not be handled` ) . toBe ( false ) ;
454+ }
479455 } ,
480456 } ) ;
481457 } ) ;
482458
483459 it ( "falls through POST requests when basePath is empty" , async ( ) => {
484460 await withControlUiRoot ( {
485461 fn : async ( tmp ) => {
486- expectUnhandledRoutes ( {
487- urls : [ "/webhook/bluebubbles" ] ,
462+ const { handled , end } = runControlUiRequest ( {
463+ url : "/webhook/bluebubbles" ,
488464 method : "POST" ,
489465 rootPath : tmp ,
490- expectationLabel : "POST webhook should fall through" ,
491466 } ) ;
467+ expect ( handled ) . toBe ( false ) ;
468+ expect ( end ) . not . toHaveBeenCalled ( ) ;
492469 } ,
493470 } ) ;
494471 } ) ;
495472
496473 it ( "falls through POST requests under configured basePath (plugin webhook passthrough)" , async ( ) => {
497474 await withControlUiRoot ( {
498475 fn : async ( tmp ) => {
499- expectUnhandledRoutes ( {
500- urls : [ "/openclaw" , "/openclaw/" , "/openclaw/some-page" ] ,
501- method : "POST" ,
502- rootPath : tmp ,
503- basePath : "/openclaw" ,
504- expectationLabel : "POST under basePath should pass through to plugin handlers" ,
505- } ) ;
476+ for ( const route of [ "/openclaw" , "/openclaw/" , "/openclaw/some-page" ] ) {
477+ const { handled, end } = runControlUiRequest ( {
478+ url : route ,
479+ method : "POST" ,
480+ rootPath : tmp ,
481+ basePath : "/openclaw" ,
482+ } ) ;
483+ expect ( handled , `POST to ${ route } should pass through to plugin handlers` ) . toBe ( false ) ;
484+ expect ( end , `POST to ${ route } should not write a response` ) . not . toHaveBeenCalled ( ) ;
485+ }
506486 } ,
507487 } ) ;
508488 } ) ;
0 commit comments