@@ -6,14 +6,18 @@ import { paths } from "@dokploy/server/constants";
66import AdmZip from "adm-zip" ;
77import { afterAll , beforeAll , describe , expect , it , vi } from "vitest" ;
88
9+ const OUTPUT_BASE = "./__test__/drop/zips/output" ;
910const { APPLICATIONS_PATH } = paths ( ) ;
1011vi . mock ( "@dokploy/server/constants" , async ( importOriginal ) => {
1112 const actual = await importOriginal ( ) ;
1213 return {
1314 // @ts -ignore
1415 ...actual ,
1516 paths : ( ) => ( {
16- APPLICATIONS_PATH : "./__test__/drop/zips/output" ,
17+ // @ts -ignore
18+ ...actual . paths ( ) ,
19+ BASE_PATH : OUTPUT_BASE ,
20+ APPLICATIONS_PATH : OUTPUT_BASE ,
1721 } ) ,
1822 } ;
1923} ) ;
@@ -150,6 +154,176 @@ const baseApp: ApplicationNested = {
150154 ulimitsSwarm : null ,
151155} ;
152156
157+ /**
158+ * GHSA-66v7-g3fh-47h3: Remote Code Execution through Path Traversal.
159+ * Validates the exact PoC: ZIP with path traversal entry ../../../../../etc/cron.d/malicious-cron
160+ * plus cover files (package.json, index.js). unzipDrop must reject and never write outside output.
161+ */
162+ describe ( "GHSA-66v7-g3fh-47h3 path traversal RCE" , ( ) => {
163+ beforeAll ( async ( ) => {
164+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
165+ } ) ;
166+ afterAll ( async ( ) => {
167+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
168+ } ) ;
169+
170+ it ( "rejects PoC ZIP: traversal ../../../../../etc/cron.d/malicious-cron + package.json + index.js" , async ( ) => {
171+ baseApp . appName = "ghsa-rce" ;
172+ // PoC payload: same entry name as advisory (Python zipfile keeps it; AdmZip normalizes on add → use placeholder + replace)
173+ const traversalEntry = "../../../../../etc/cron.d/malicious-cron" ;
174+ const cronPayload = "* * * * * root id\n" ;
175+ const placeholder = "x" . repeat ( traversalEntry . length ) ;
176+ const zip = new AdmZip ( ) ;
177+ zip . addFile (
178+ "package.json" ,
179+ Buffer . from ( '{"name": "app", "version": "1.0.0"}' ) ,
180+ ) ;
181+ zip . addFile ( "index.js" , Buffer . from ( 'console.log("Application");' ) ) ;
182+ zip . addFile ( placeholder , Buffer . from ( cronPayload ) ) ;
183+ let buf = Buffer . from ( zip . toBuffer ( ) ) ;
184+ buf = Buffer . from (
185+ buf . toString ( "binary" ) . split ( placeholder ) . join ( traversalEntry ) ,
186+ "binary" ,
187+ ) ;
188+ const file = new File ( [ buf as unknown as ArrayBuffer ] , "exploit.zip" ) ;
189+ await expect ( unzipDrop ( file , baseApp ) ) . rejects . toThrow (
190+ / P a t h t r a v e r s a l d e t e c t e d .* r e s o l v e d p a t h e s c a p e s o u t p u t d i r e c t o r y / ,
191+ ) ;
192+ } ) ;
193+ } ) ;
194+
195+ describe ( "security: existing symlink escape" , ( ) => {
196+ beforeAll ( async ( ) => {
197+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
198+ } ) ;
199+
200+ afterAll ( async ( ) => {
201+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
202+ } ) ;
203+
204+ it ( "should NOT write outside base when directory is a symlink" , async ( ) => {
205+ const appName = "symlink-existing" ;
206+ const output = path . join ( APPLICATIONS_PATH , appName , "code" ) ;
207+ await fs . mkdir ( output , { recursive : true } ) ;
208+
209+ // outside target (attacker wants to write here)
210+ const outside = path . join ( APPLICATIONS_PATH , ".." , "outside" ) ;
211+ await fs . mkdir ( outside , { recursive : true } ) ;
212+
213+ // attacker-controlled symlink inside project
214+ await fs . symlink ( outside , path . join ( output , "logs" ) ) ;
215+
216+ // zip looks totally harmless
217+ const zip = new AdmZip ( ) ;
218+ zip . addFile ( "logs/pwned.txt" , Buffer . from ( "owned" ) ) ;
219+
220+ const file = new File ( [ zip . toBuffer ( ) as any ] , "exploit.zip" ) ;
221+
222+ await unzipDrop ( file , { ...baseApp , appName } ) ;
223+
224+ // if vulnerable -> file exists outside sandbox
225+ const escaped = await fs
226+ . readFile ( path . join ( outside , "pwned.txt" ) , "utf8" )
227+ . then ( ( ) => true )
228+ . catch ( ( ) => false ) ;
229+
230+ expect ( escaped ) . toBe ( false ) ;
231+ } ) ;
232+ } ) ;
233+
234+ describe ( "security: zip symlink entry blocked" , ( ) => {
235+ beforeAll ( async ( ) => {
236+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
237+ } ) ;
238+
239+ afterAll ( async ( ) => {
240+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
241+ } ) ;
242+
243+ it ( "rejects zip containing real symlink entry" , async ( ) => {
244+ const appName = "zip-symlink" ;
245+
246+ const zipBuffer = await fs . readFile (
247+ path . join ( __dirname , "./zips/payload/symlink-entry.zip" ) ,
248+ ) ;
249+
250+ const file = new File ( [ zipBuffer as any ] , "exploit.zip" ) ;
251+
252+ await expect ( unzipDrop ( file , { ...baseApp , appName } ) ) . rejects . toThrow (
253+ / D a n g e r o u s n o d e e n t r i e s a r e n o t a l l o w e d / ,
254+ ) ;
255+ } ) ;
256+ } ) ;
257+
258+ describe ( "unzipDrop path under output (no traversal)" , ( ) => {
259+ beforeAll ( async ( ) => {
260+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
261+ } ) ;
262+ afterAll ( async ( ) => {
263+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
264+ } ) ;
265+
266+ it ( "allows entry etc/cron.d/malicious-cron when under output (no path traversal)" , async ( ) => {
267+ baseApp . appName = "cron-under-output" ;
268+ const zip = new AdmZip ( ) ;
269+ zip . addFile (
270+ "etc/cron.d/malicious-cron" ,
271+ Buffer . from ( "* * * * * root id\n" ) ,
272+ ) ;
273+ zip . addFile ( "package.json" , Buffer . from ( '{"name":"app"}' ) ) ;
274+ const file = new File (
275+ [ zip . toBuffer ( ) as unknown as ArrayBuffer ] ,
276+ "app.zip" ,
277+ ) ;
278+ const outputPath = path . join ( APPLICATIONS_PATH , baseApp . appName , "code" ) ;
279+ await unzipDrop ( file , baseApp ) ;
280+ const content = await fs . readFile (
281+ path . join ( outputPath , "etc/cron.d/malicious-cron" ) ,
282+ "utf8" ,
283+ ) ;
284+ expect ( content ) . toBe ( "* * * * * root id\n" ) ;
285+ } ) ;
286+ } ) ;
287+
288+ describe ( "security: traversal inside BASE_PATH (sandbox escape)" , ( ) => {
289+ beforeAll ( async ( ) => {
290+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
291+ } ) ;
292+
293+ afterAll ( async ( ) => {
294+ await fs . rm ( APPLICATIONS_PATH , { recursive : true , force : true } ) ;
295+ } ) ;
296+
297+ it ( "should NOT allow writing outside application directory but inside BASE_PATH" , async ( ) => {
298+ const appName = "sandbox-escape" ;
299+
300+ const base = APPLICATIONS_PATH . replace ( "/applications" , "" ) ;
301+ const output = path . join ( APPLICATIONS_PATH , appName , "code" ) ;
302+
303+ await fs . mkdir ( output , { recursive : true } ) ;
304+
305+ // attacker writes into traefik config inside base
306+ const zip = new AdmZip ( ) ;
307+ zip . addFile (
308+ "../../../traefik/dynamic/evil.yml" ,
309+ Buffer . from ( "pwned: true" ) ,
310+ ) ;
311+
312+ const file = new File ( [ zip . toBuffer ( ) as any ] , "exploit.zip" ) ;
313+
314+ await unzipDrop ( file , { ...baseApp , appName } ) ;
315+
316+ const escapedPath = path . join ( base , "traefik/dynamic/evil.yml" ) ;
317+
318+ const exists = await fs
319+ . readFile ( escapedPath )
320+ . then ( ( ) => true )
321+ . catch ( ( ) => false ) ;
322+
323+ expect ( exists ) . toBe ( false ) ;
324+ } ) ;
325+ } ) ;
326+
153327describe ( "unzipDrop using real zip files" , ( ) => {
154328 // const { APPLICATIONS_PATH } = paths();
155329 beforeAll ( async ( ) => {
@@ -166,14 +340,12 @@ describe("unzipDrop using real zip files", () => {
166340 try {
167341 const outputPath = path . join ( APPLICATIONS_PATH , baseApp . appName , "code" ) ;
168342 const zip = new AdmZip ( "./__test__/drop/zips/single-file.zip" ) ;
169- console . log ( `Output Path: ${ outputPath } ` ) ;
170343 const zipBuffer = zip . toBuffer ( ) as Buffer < ArrayBuffer > ;
171344 const file = new File ( [ zipBuffer ] , "single.zip" ) ;
172345 await unzipDrop ( file , baseApp ) ;
173346 const files = await fs . readdir ( outputPath , { withFileTypes : true } ) ;
174347 expect ( files . some ( ( f ) => f . name === "test.txt" ) ) . toBe ( true ) ;
175348 } catch ( err ) {
176- console . log ( err ) ;
177349 } finally {
178350 }
179351 } ) ;
0 commit comments