11import { spawnSync , spawn } from "child_process" ;
22import { resolve } from "path" ;
33import { test , before } from "node:test" ;
4- import { equal , fail , match } from "node:assert" ;
4+ import { deepStrictEqual , fail , match } from "node:assert" ;
55import { getRandomPort } from "./utils/get-port.mjs" ;
66import { timeout } from "./utils/timeout.mjs" ;
77
@@ -10,6 +10,7 @@ const pathToAppDir = resolve(
1010 "../../sample-apps/react2shell-next"
1111) ;
1212const port = await getRandomPort ( ) ;
13+ const port2 = await getRandomPort ( ) ;
1314
1415before ( ( ) => {
1516 const { stderr } = spawnSync ( `npm` , [ "run" , "build" ] , {
@@ -21,77 +22,72 @@ before(() => {
2122 }
2223} ) ;
2324
24- function sendReact2ShellRequest ( port ) {
25- // Based on https://github.com/assetnote/react2shell-scanner/
26-
27- const cmd = "echo $((41*271))" ;
28-
29- const prefixPayload =
30- "var res=process.mainModule.require('child_process').execSync('{cmd}').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{{digest: `NEXT_REDIRECT;push;/login?a=${{res}};307;`}});" . replace (
31- "{cmd}" ,
32- cmd
33- ) ;
25+ async function testReact2Shell ( targetUrl ) {
26+ const boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad" ;
3427
35- const part0 =
36- '{"then":"$1:__proto__:then","status":"resolved_model","reason":-1,"value":"{\\"then\\":\\"$B1337\\"}","_response":{"_prefix":"${prefixPayload}","_chunks":"$Q2","_formData":{"get":"$1:constructor:constructor"}}}' . replace (
37- "{prefixPayload}" ,
38- prefixPayload
39- ) ;
28+ const part0 = JSON . stringify ( {
29+ then : "$1:__proto__:then" ,
30+ status : "resolved_model" ,
31+ reason : - 1 ,
32+ value : '{"then":"$B1337"}' ,
33+ _response : {
34+ _prefix :
35+ "var res=process.mainModule.require('child_process').execSync('echo $((41*271))').toString().trim();;throw Object.assign(new Error('NEXT_REDIRECT'),{digest: `NEXT_REDIRECT;push;/login?a=${res};307;`});" ,
36+ _chunks : "$Q2" ,
37+ _formData : { get : "$1:constructor:constructor" } ,
38+ } ,
39+ } ) ;
4040
41- // Build the multipart body as a string
42- const boundary = "----WebKitFormBoundaryx8jO2oVc6SWP3Sad" ;
43- const parts = [ ] ;
44- parts . push (
45- `${ boundary } \r\n` +
46- 'Content-Disposition: form-data; name="0"\r\n\r\n' +
47- `${ part0 } \r\n`
48- ) ;
49- parts . push (
50- `${ boundary } \r\n` +
51- 'Content-Disposition: form-data; name="1"\r\n\r\n' +
52- '"$@0"\r\n'
53- ) ;
54- parts . push (
55- `${ boundary } \r\n` +
56- 'Content-Disposition: form-data; name="2"\r\n\r\n' +
57- "[]\r\n"
58- ) ;
59- parts . push ( `${ boundary } --` ) ;
60- const body = parts . join ( "" ) ;
61-
62- return fetch ( `http://127.0.0.1:${ port } /` , {
41+ const body = [
42+ `------WebKitFormBoundaryx8jO2oVc6SWP3Sad` ,
43+ `Content-Disposition: form-data; name="0"` ,
44+ `` ,
45+ part0 ,
46+ `------WebKitFormBoundaryx8jO2oVc6SWP3Sad` ,
47+ `Content-Disposition: form-data; name="1"` ,
48+ `` ,
49+ `"$@0"` ,
50+ `------WebKitFormBoundaryx8jO2oVc6SWP3Sad` ,
51+ `Content-Disposition: form-data; name="2"` ,
52+ `` ,
53+ `[]` ,
54+ `------WebKitFormBoundaryx8jO2oVc6SWP3Sad--` ,
55+ ] . join ( "\r\n" ) ;
56+
57+ const response = await fetch ( targetUrl , {
58+ method : "POST" ,
6359 headers : {
64- "User-Agent" :
65- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.113 Safari/537.36 Assetnote/1.0.0" ,
60+ "Content-Type" : `multipart/form-data; boundary=${ boundary } ` ,
6661 "Next-Action" : "x" ,
6762 "X-Nextjs-Request-Id" : "b5dce965" ,
68- "X-Nextjs-Html-Request-Id" : "SSTMXm7OJ_g0Ncx6jpQt9" ,
69- "Content-Type" : `multipart/form-data; boundary=${ boundary . slice ( 2 ) } ` ,
7063 } ,
71- body,
72- method : "POST " ,
64+ body : body ,
65+ redirect : "manual " ,
7366 } ) ;
67+
68+ const redirectHeader = response . headers . get ( "X-Action-Redirect" ) || "" ;
69+ const isVulnerable = / .* \/ l o g i n \? a = 1 1 1 1 1 .* / . test ( redirectHeader ) ;
70+
71+ return {
72+ vulnerable : isVulnerable ,
73+ statusCode : response . status ,
74+ redirectHeader : redirectHeader ,
75+ } ;
7476}
7577
76- test ( "Request is not blocked in monitoring mode" , async ( ) => {
77- const server = spawn (
78- `node` ,
79- [ "-r" , "@aikidosec/firewall" , "./.next/standalone/server.js" ] ,
80- {
81- cwd : pathToAppDir ,
82- env : {
83- ...process . env ,
84- AIKIDO_DEBUG : "true" ,
85- AIKIDO_BLOCK : "false" ,
86- PORT : port ,
87- HOSTNAME : "127.0.0.1" ,
88- } ,
89- }
90- ) ;
78+ test ( "vulnerable to RCE without Zen" , async ( ) => {
79+ const server = spawn ( `node` , [ "./.next/standalone/server.js" ] , {
80+ cwd : pathToAppDir ,
81+ env : {
82+ ...process . env ,
83+ PORT : port ,
84+ HOSTNAME : "127.0.0.1" ,
85+ } ,
86+ } ) ;
9187
9288 try {
9389 server . on ( "error" , ( err ) => {
94- fail ( err . message ) ;
90+ fail ( err ) ;
9591 } ) ;
9692
9793 let stdout = "" ;
@@ -107,17 +103,73 @@ test("Request is not blocked in monitoring mode", async () => {
107103 // Wait for the server to start
108104 await timeout ( 2000 ) ;
109105
110- const result = await sendReact2ShellRequest ( port ) ;
106+ const result = await testReact2Shell ( `http://127.0.0.1:${ port } ` ) ;
107+ deepStrictEqual ( result , {
108+ vulnerable : true ,
109+ statusCode : 303 ,
110+ redirectHeader : "/login?a=11111;push" ,
111+ } ) ;
112+ } catch ( err ) {
113+ fail ( err ) ;
114+ } finally {
115+ server . kill ( ) ;
116+ }
117+ } ) ;
111118
112- equal ( result . status , 500 ) ;
113- const response = await result . text ( ) ;
114- equal ( response . includes ( 'E{"digest":"' ) , true ) ;
119+ test ( "not vulnerable to RCE with Zen" , async ( ) => {
120+ const server = spawn ( `node` , [ "./.next/standalone/server.js" ] , {
121+ cwd : pathToAppDir ,
122+ env : {
123+ ...process . env ,
124+ AIKIDO_DEBUG : "true" ,
125+ AIKIDO_BLOCK : "true" ,
126+ PORT : port2 ,
127+ HOSTNAME : "127.0.0.1" ,
128+ NODE_OPTIONS : "-r @aikidosec/firewall" ,
129+ } ,
130+ } ) ;
131+
132+ try {
133+ server . on ( "error" , ( err ) => {
134+ fail ( err ) ;
135+ } ) ;
136+
137+ let stdout = "" ;
138+ server . stdout . on ( "data" , ( data ) => {
139+ stdout += data . toString ( ) ;
140+ } ) ;
141+
142+ let stderr = "" ;
143+ server . stderr . on ( "data" , ( data ) => {
144+ stderr += data . toString ( ) ;
145+ } ) ;
146+
147+ // Wait for the server to start
148+ await timeout ( 2000 ) ;
149+
150+ const result = await testReact2Shell ( `http://127.0.0.1:${ port2 } ` ) ;
151+ deepStrictEqual ( result , {
152+ vulnerable : false ,
153+ statusCode : 500 ,
154+ redirectHeader : "" ,
155+ } ) ;
115156
116157 match ( stdout , / S t a r t i n g a g e n t / ) ;
117- //match(stderr, /Zen has blocked an SQL injection/);
158+ match (
159+ stderr ,
160+ new RegExp (
161+ escapeStringRegexp (
162+ "Zen has blocked a JavaScript injection: new Function/eval(...) originating from body.fields.[0].value._response._prefix"
163+ )
164+ )
165+ ) ;
118166 } catch ( err ) {
119167 fail ( err ) ;
120168 } finally {
121169 server . kill ( ) ;
122170 }
123171} ) ;
172+
173+ function escapeStringRegexp ( string ) {
174+ return string . replace ( / [ | \\ { } ( ) [ \] ^ $ + * ? . ] / g, "\\$&" ) . replace ( / - / g, "\\x2d" ) ;
175+ }
0 commit comments