11import { afterEach , beforeEach , describe , expect , it } from "bun:test" ;
2- import { mkdirSync , rmSync , utimesSync , writeFileSync } from "node:fs" ;
2+ import { existsSync , mkdirSync , mkdtempSync , rmSync , utimesSync , writeFileSync } from "node:fs" ;
33import { tmpdir } from "node:os" ;
44import { join } from "node:path" ;
55import { CACHE_TTL_MS , getCacheDir , getCacheKey , readCache , writeCache } from "./cache.ts" ;
66
7- // Use env-var override to redirect the cache to an isolated temp directory
8- const TEST_CACHE_DIR = join ( tmpdir ( ) , `gcs-cache-test-${ process . pid } ` ) ;
7+ // Use env-var override to redirect the cache to a process-private temp directory.
8+ // mkdtempSync creates the directory with 0o700 permissions (owner-only access),
9+ // which is the safe temp-dir pattern recognised by static analysers (CWE-377).
10+ let TEST_CACHE_DIR : string ;
911
1012beforeEach ( ( ) => {
13+ TEST_CACHE_DIR = mkdtempSync ( join ( tmpdir ( ) , "gcs-cache-test-" ) ) ;
1114 process . env . GITHUB_CODE_SEARCH_CACHE_DIR = TEST_CACHE_DIR ;
12- mkdirSync ( TEST_CACHE_DIR , { recursive : true } ) ;
1315} ) ;
1416
1517afterEach ( ( ) => {
@@ -47,7 +49,10 @@ describe("getCacheDir", () => {
4749 const originalPlatform = Object . getOwnPropertyDescriptor ( process , "platform" ) ;
4850 const originalLocalAppData = process . env . LOCALAPPDATA ;
4951 try {
50- Object . defineProperty ( process , "platform" , { value : "win32" , configurable : true } ) ;
52+ Object . defineProperty ( process , "platform" , {
53+ value : "win32" ,
54+ configurable : true ,
55+ } ) ;
5156 process . env . LOCALAPPDATA = "C:\\Users\\user\\AppData\\Local" ;
5257 const dir = getCacheDir ( ) ;
5358 // path.join uses the host OS separator in tests (macOS: /), so we just
@@ -67,7 +72,10 @@ describe("getCacheDir", () => {
6772 const originalPlatform = Object . getOwnPropertyDescriptor ( process , "platform" ) ;
6873 const originalLocalAppData = process . env . LOCALAPPDATA ;
6974 try {
70- Object . defineProperty ( process , "platform" , { value : "win32" , configurable : true } ) ;
75+ Object . defineProperty ( process , "platform" , {
76+ value : "win32" ,
77+ configurable : true ,
78+ } ) ;
7179 delete process . env . LOCALAPPDATA ;
7280 const dir = getCacheDir ( ) ;
7381 expect ( dir ) . toContain ( "AppData" ) ;
@@ -84,7 +92,10 @@ describe("getCacheDir", () => {
8492 const originalPlatform = Object . getOwnPropertyDescriptor ( process , "platform" ) ;
8593 const originalXdg = process . env . XDG_CACHE_HOME ;
8694 try {
87- Object . defineProperty ( process , "platform" , { value : "linux" , configurable : true } ) ;
95+ Object . defineProperty ( process , "platform" , {
96+ value : "linux" ,
97+ configurable : true ,
98+ } ) ;
8899 process . env . XDG_CACHE_HOME = "/custom/xdg/cache" ;
89100 const dir = getCacheDir ( ) ;
90101 expect ( dir ) . toContain ( "custom" ) ;
@@ -102,7 +113,10 @@ describe("getCacheDir", () => {
102113 const originalPlatform = Object . getOwnPropertyDescriptor ( process , "platform" ) ;
103114 const originalXdg = process . env . XDG_CACHE_HOME ;
104115 try {
105- Object . defineProperty ( process , "platform" , { value : "linux" , configurable : true } ) ;
116+ Object . defineProperty ( process , "platform" , {
117+ value : "linux" ,
118+ configurable : true ,
119+ } ) ;
106120 delete process . env . XDG_CACHE_HOME ;
107121 const dir = getCacheDir ( ) ;
108122 expect ( dir ) . toContain ( ".cache" ) ;
@@ -117,7 +131,10 @@ describe("getCacheDir", () => {
117131 delete process . env . GITHUB_CODE_SEARCH_CACHE_DIR ;
118132 const originalPlatform = Object . getOwnPropertyDescriptor ( process , "platform" ) ;
119133 try {
120- Object . defineProperty ( process , "platform" , { value : "freebsd" , configurable : true } ) ;
134+ Object . defineProperty ( process , "platform" , {
135+ value : "freebsd" ,
136+ configurable : true ,
137+ } ) ;
121138 const dir = getCacheDir ( ) ;
122139 expect ( dir ) . toContain ( ".github-code-search" ) ;
123140 expect ( dir ) . toContain ( "cache" ) ;
@@ -224,3 +241,61 @@ describe("writeCache / readCache round-trip", () => {
224241 expect ( ( ) => writeCache ( "key.json" , { data : 1 } ) ) . not . toThrow ( ) ;
225242 } ) ;
226243} ) ;
244+
245+ // ─── Path Traversal Security Tests ────────────────────────────────────────────
246+
247+ describe ( "Path traversal vulnerability mitigation" , ( ) => {
248+ it ( "rejects readCache with relative parent directory traversal (../)" , ( ) => {
249+ // Attempt to read outside the cache directory using ../
250+ const maliciousKey = "../../../etc/passwd" ;
251+ const result = readCache ( maliciousKey ) ;
252+ expect ( result ) . toBeNull ( ) ;
253+ } ) ;
254+
255+ it ( "rejects writeCache with relative parent directory traversal (../)" , ( ) => {
256+ // Use a subdirectory as cache dir so we have a writable location just above it
257+ const cacheSubDir = join ( TEST_CACHE_DIR , "sub" ) ;
258+ mkdirSync ( cacheSubDir , { recursive : true } ) ;
259+ process . env . GITHUB_CODE_SEARCH_CACHE_DIR = cacheSubDir ;
260+ // This key resolves to TEST_CACHE_DIR/escaped.json — one level above cacheSubDir
261+ const maliciousKey = "../escaped.json" ;
262+ const outsidePath = join ( TEST_CACHE_DIR , "escaped.json" ) ;
263+ expect ( ( ) => writeCache ( maliciousKey , { exploit : true } ) ) . not . toThrow ( ) ;
264+ // Prove the file was NOT created at the resolved outside location
265+ expect ( existsSync ( outsidePath ) ) . toBe ( false ) ;
266+ } ) ;
267+
268+ it ( "rejects readCache with absolute path" , ( ) => {
269+ // Attempt to read an absolute path outside the cache directory
270+ // Use a portable OS temp path that exists on all platforms
271+ const maliciousKey = join ( tmpdir ( ) , "gcs-read-absolute-test.json" ) ;
272+ const result = readCache ( maliciousKey ) ;
273+ expect ( result ) . toBeNull ( ) ;
274+ } ) ;
275+
276+ it ( "rejects writeCache with absolute path" , ( ) => {
277+ // Attempt to write to an absolute path outside the cache directory
278+ const outsidePath = join ( tmpdir ( ) , `gcs-write-absolute-test-${ process . pid } .json` ) ;
279+ expect ( ( ) => writeCache ( outsidePath , { exploit : "absolute" } ) ) . not . toThrow ( ) ;
280+ // Prove the file was NOT created at the absolute outside location
281+ expect ( existsSync ( outsidePath ) ) . toBe ( false ) ;
282+ } ) ;
283+
284+ it ( "rejects readCache with encoded path traversal (%2e%2e/)" , ( ) => {
285+ // URL-encoded path traversal attempt
286+ const maliciousKey = "%2e%2e/%2e%2e/etc/passwd" ;
287+ const result = readCache ( maliciousKey ) ;
288+ // URL-encoded ".." segments that also contain a literal "/" path separator.
289+ // readCache rejects it because the key contains a literal "/" (separator check),
290+ // not because it decodes the percent-encoded characters.
291+ expect ( result ) . toBeNull ( ) ;
292+ } ) ;
293+
294+ it ( "allows reading legitimate cache files with safe names" , ( ) => {
295+ // Verify that normal operation still works
296+ const safeKey = "teams__myorg__squad-.json" ;
297+ const data = { teams : [ "squad-alpha" ] } ;
298+ writeCache ( safeKey , data ) ;
299+ expect ( readCache ( safeKey ) ) . toEqual ( data ) ;
300+ } ) ;
301+ } ) ;
0 commit comments