1- const { cors, getUserAgent, replaceSecretPlaceholder, isPrivateTarget } = require ( "#server_functions" ) ;
2-
3- const mockLookup = vi . fn ( ( ) => Promise . resolve ( [ { address : "93.184.216.34" , family : 4 } ] ) ) ;
4-
5- vi . mock ( "node:dns" , ( ) => ( {
6- promises : {
7- lookup : mockLookup
8- }
9- } ) ) ;
1+ // Tests use vi.spyOn on shared module objects (dns, global.fetch).
2+ // vi.spyOn modifies the object property directly on the cached module instance, so it
3+ // is intercepted by server_functions.js regardless of the Module.prototype.require override
4+ // in vitest-setup.js. restoreAllMocks:true auto-restores spies, but may reuse the same
5+ // spy instance — mockClear() is called explicitly in beforeEach to reset call history.
6+ const dns = require ( "node:dns" ) ;
7+ const { cors, getUserAgent, replaceSecretPlaceholder } = require ( "#server_functions" ) ;
108
119describe ( "server_functions tests" , ( ) => {
1210 describe ( "The replaceSecretPlaceholder method" , ( ) => {
@@ -27,29 +25,29 @@ describe("server_functions tests", () => {
2725 } ) ;
2826
2927 describe ( "The cors method" , ( ) => {
30- let fetchResponse ;
28+ let fetchSpy ;
3129 let fetchResponseHeadersGet ;
3230 let fetchResponseArrayBuffer ;
3331 let corsResponse ;
3432 let request ;
35- let fetchMock ;
3633
3734 beforeEach ( ( ) => {
3835 global . config = { cors : "allowAll" } ;
3936 fetchResponseHeadersGet = vi . fn ( ( ) => { } ) ;
4037 fetchResponseArrayBuffer = vi . fn ( ( ) => { } ) ;
41- fetchResponse = {
42- headers : {
43- get : fetchResponseHeadersGet
44- } ,
45- arrayBuffer : fetchResponseArrayBuffer ,
46- ok : true
47- } ;
4838
49- fetch = vi . fn ( ) ;
50- fetch . mockImplementation ( ( ) => fetchResponse ) ;
39+ // Mock DNS to return a public IP (SSRF check must pass for these tests)
40+ vi . spyOn ( dns . promises , "lookup" ) . mockResolvedValue ( { address : "93.184.216.34" , family : 4 } ) ;
5141
52- fetchMock = fetch ;
42+ // vi.spyOn may return the same spy instance across tests when restoreAllMocks
43+ // restores-but-reuses; mockClear() explicitly resets call history each time.
44+ fetchSpy = vi . spyOn ( global , "fetch" ) ;
45+ fetchSpy . mockClear ( ) ;
46+ fetchSpy . mockImplementation ( ( ) => Promise . resolve ( {
47+ headers : { get : fetchResponseHeadersGet } ,
48+ arrayBuffer : fetchResponseArrayBuffer ,
49+ ok : true
50+ } ) ) ;
5351
5452 corsResponse = {
5553 set : vi . fn ( ( ) => { } ) ,
@@ -72,8 +70,8 @@ describe("server_functions tests", () => {
7270
7371 await cors ( request , corsResponse ) ;
7472
75- expect ( fetchMock . mock . calls ) . toHaveLength ( 1 ) ;
76- expect ( fetchMock . mock . calls [ 0 ] [ 0 ] ) . toBe ( urlToCall ) ;
73+ expect ( fetchSpy . mock . calls ) . toHaveLength ( 1 ) ;
74+ expect ( fetchSpy . mock . calls [ 0 ] [ 0 ] ) . toBe ( urlToCall ) ;
7775 } ) ;
7876
7977 it ( "Forwards Content-Type if json" , async ( ) => {
@@ -135,9 +133,9 @@ describe("server_functions tests", () => {
135133 it ( "Fetches with user agent by default" , async ( ) => {
136134 await cors ( request , corsResponse ) ;
137135
138- expect ( fetchMock . mock . calls ) . toHaveLength ( 1 ) ;
139- expect ( fetchMock . mock . calls [ 0 ] [ 1 ] ) . toHaveProperty ( "headers" ) ;
140- expect ( fetchMock . mock . calls [ 0 ] [ 1 ] . headers ) . toHaveProperty ( "User-Agent" ) ;
136+ expect ( fetchSpy . mock . calls ) . toHaveLength ( 1 ) ;
137+ expect ( fetchSpy . mock . calls [ 0 ] [ 1 ] ) . toHaveProperty ( "headers" ) ;
138+ expect ( fetchSpy . mock . calls [ 0 ] [ 1 ] . headers ) . toHaveProperty ( "User-Agent" ) ;
141139 } ) ;
142140
143141 it ( "Fetches with specified headers" , async ( ) => {
@@ -147,10 +145,10 @@ describe("server_functions tests", () => {
147145
148146 await cors ( request , corsResponse ) ;
149147
150- expect ( fetchMock . mock . calls ) . toHaveLength ( 1 ) ;
151- expect ( fetchMock . mock . calls [ 0 ] [ 1 ] ) . toHaveProperty ( "headers" ) ;
152- expect ( fetchMock . mock . calls [ 0 ] [ 1 ] . headers ) . toHaveProperty ( "header1" , "value1" ) ;
153- expect ( fetchMock . mock . calls [ 0 ] [ 1 ] . headers ) . toHaveProperty ( "header2" , "value2" ) ;
148+ expect ( fetchSpy . mock . calls ) . toHaveLength ( 1 ) ;
149+ expect ( fetchSpy . mock . calls [ 0 ] [ 1 ] ) . toHaveProperty ( "headers" ) ;
150+ expect ( fetchSpy . mock . calls [ 0 ] [ 1 ] . headers ) . toHaveProperty ( "header1" , "value1" ) ;
151+ expect ( fetchSpy . mock . calls [ 0 ] [ 1 ] . headers ) . toHaveProperty ( "header2" , "value2" ) ;
154152 } ) ;
155153
156154 it ( "Sends specified headers" , async ( ) => {
@@ -162,8 +160,8 @@ describe("server_functions tests", () => {
162160
163161 await cors ( request , corsResponse ) ;
164162
165- expect ( fetchMock . mock . calls ) . toHaveLength ( 1 ) ;
166- expect ( fetchMock . mock . calls [ 0 ] [ 1 ] ) . toHaveProperty ( "headers" ) ;
163+ expect ( fetchSpy . mock . calls ) . toHaveLength ( 1 ) ;
164+ expect ( fetchSpy . mock . calls [ 0 ] [ 1 ] ) . toHaveProperty ( "headers" ) ;
167165 expect ( corsResponse . set . mock . calls ) . toHaveLength ( 3 ) ;
168166 expect ( corsResponse . set . mock . calls [ 0 ] [ 0 ] ) . toBe ( "Content-Type" ) ;
169167 expect ( corsResponse . set . mock . calls [ 1 ] [ 0 ] ) . toBe ( "header1" ) ;
@@ -192,94 +190,92 @@ describe("server_functions tests", () => {
192190 } ) ;
193191 } ) ;
194192
195- describe ( "The isPrivateTarget method" , ( ) => {
193+ describe ( "The cors method blocks SSRF (DNS rebinding safe)" , ( ) => {
194+ let response ;
195+
196196 beforeEach ( ( ) => {
197- mockLookup . mockReset ( ) ;
197+ response = {
198+ set : vi . fn ( ) ,
199+ send : vi . fn ( ) ,
200+ status : vi . fn ( function ( ) { return this ; } ) ,
201+ json : vi . fn ( )
202+ } ;
198203 } ) ;
199204
200- it ( "Blocks unparseable URLs" , async ( ) => {
201- expect ( await isPrivateTarget ( "not a url" ) ) . toBe ( true ) ;
205+ it ( "Blocks localhost hostname without DNS" , async ( ) => {
206+ await cors ( { url : "/cors?url=http://localhost/path" } , response ) ;
207+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
208+ expect ( response . json ) . toHaveBeenCalledWith ( { error : "Forbidden: private or reserved addresses are not allowed" } ) ;
202209 } ) ;
203210
204211 it ( "Blocks non-http protocols" , async ( ) => {
205- expect ( await isPrivateTarget ( "file:///etc/passwd" ) ) . toBe ( true ) ;
206- expect ( await isPrivateTarget ( "ftp://internal/file" ) ) . toBe ( true ) ;
207- } ) ;
208-
209- it ( "Blocks localhost" , async ( ) => {
210- expect ( await isPrivateTarget ( "http://localhost/path" ) ) . toBe ( true ) ;
211- expect ( await isPrivateTarget ( "http://LOCALHOST:8080/" ) ) . toBe ( true ) ;
212- } ) ;
213-
214- it ( "Blocks private IPs (loopback)" , async ( ) => {
215- mockLookup . mockResolvedValue ( [ { address : "127.0.0.1" , family : 4 } ] ) ;
216- expect ( await isPrivateTarget ( "http://loopback.example.com/" ) ) . toBe ( true ) ;
212+ await cors ( { url : "/cors?url=ftp://example.com/file" } , response ) ;
213+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
217214 } ) ;
218215
219- it ( "Blocks private IPs (RFC 1918) " , async ( ) => {
220- mockLookup . mockResolvedValue ( [ { address : "192.168.1.1" , family : 4 } ] ) ;
221- expect ( await isPrivateTarget ( "http://internal.example.com/" ) ) . toBe ( true ) ;
216+ it ( "Blocks invalid URLs " , async ( ) => {
217+ await cors ( { url : "/cors?url=not_a_valid_url" } , response ) ;
218+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
222219 } ) ;
223220
224- it ( "Blocks link-local addresses" , async ( ) => {
225- mockLookup . mockResolvedValue ( [ { address : "169.254.169.254" , family : 4 } ] ) ;
226- expect ( await isPrivateTarget ( "http://metadata.example.com/" ) ) . toBe ( true ) ;
221+ it ( "Blocks loopback addresses (127.0.0.1)" , async ( ) => {
222+ vi . spyOn ( dns . promises , "lookup" ) . mockResolvedValue ( { address : "127.0.0.1" , family : 4 } ) ;
223+ await cors ( { url : "/cors?url=http://example.com/" } , response ) ;
224+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
227225 } ) ;
228226
229- it ( "Blocks when DNS lookup fails" , async ( ) => {
230- mockLookup . mockRejectedValue ( new Error ( "ENOTFOUND" ) ) ;
231- expect ( await isPrivateTarget ( "http://nonexistent.invalid/" ) ) . toBe ( true ) ;
227+ it ( "Blocks RFC 1918 private addresses (192.168.x.x)" , async ( ) => {
228+ vi . spyOn ( dns . promises , "lookup" ) . mockResolvedValue ( { address : "192.168.1.1" , family : 4 } ) ;
229+ await cors ( { url : "/cors?url=http://example.com/" } , response ) ;
230+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
232231 } ) ;
233232
234- it ( "Allows public unicast IPs" , async ( ) => {
235- mockLookup . mockResolvedValue ( [ { address : "93.184.216.34" , family : 4 } ] ) ;
236- expect ( await isPrivateTarget ( "http://example.com/api" ) ) . toBe ( false ) ;
233+ it ( "Blocks link-local / cloud metadata addresses (169.254.169.254)" , async ( ) => {
234+ vi . spyOn ( dns . promises , "lookup" ) . mockResolvedValue ( { address : "169.254.169.254" , family : 4 } ) ;
235+ await cors ( { url : "/cors?url=http://example.com/" } , response ) ;
236+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
237237 } ) ;
238238
239- it ( "Blocks if any resolved address is private" , async ( ) => {
240- mockLookup . mockResolvedValue ( [
241- { address : "93.184.216.34" , family : 4 } ,
242- { address : "127.0.0.1" , family : 4 }
243- ] ) ;
244- expect ( await isPrivateTarget ( "http://dual.example.com/" ) ) . toBe ( true ) ;
239+ it ( "Allows public unicast addresses" , async ( ) => {
240+ vi . spyOn ( dns . promises , "lookup" ) . mockResolvedValue ( { address : "93.184.216.34" , family : 4 } ) ;
241+ vi . spyOn ( global , "fetch" ) . mockResolvedValue ( {
242+ ok : true ,
243+ headers : { get : vi . fn ( ) } ,
244+ arrayBuffer : vi . fn ( ( ) => new ArrayBuffer ( 0 ) )
245+ } ) ;
246+ await cors ( { url : "/cors?url=http://example.com/" } , response ) ;
247+ expect ( response . status ) . not . toHaveBeenCalledWith ( 403 ) ;
245248 } ) ;
246249 } ) ;
247250
248- describe ( "The cors method blocks SSRF" , ( ) => {
249- it ( "Returns 403 for private target URLs" , async ( ) => {
250- mockLookup . mockReset ( ) ;
251- mockLookup . mockResolvedValue ( [ { address : "127.0.0.1" , family : 4 } ] ) ;
251+ describe ( "cors method with allowWhitelist" , ( ) => {
252+ let response ;
252253
253- const request = { url : "/cors?url=http://127.0.0.1:8080/config" } ;
254- const response = {
254+ beforeEach ( ( ) => {
255+ response = {
255256 set : vi . fn ( ) ,
256257 send : vi . fn ( ) ,
257258 status : vi . fn ( function ( ) { return this ; } ) ,
258259 json : vi . fn ( )
259260 } ;
260-
261- await cors ( request , response ) ;
262-
263- expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
264- expect ( response . json ) . toHaveBeenCalledWith ( { error : "Forbidden: private or reserved addresses are not allowed" } ) ;
265- } ) ;
266- } ) ;
267-
268- describe ( "The isPrivateTarget method with allowWhitelist" , ( ) => {
269- beforeEach ( ( ) => {
270- mockLookup . mockReset ( ) ;
261+ vi . spyOn ( dns . promises , "lookup" ) . mockResolvedValue ( { address : "93.184.216.34" , family : 4 } ) ;
262+ vi . spyOn ( global , "fetch" ) . mockResolvedValue ( {
263+ ok : true ,
264+ headers : { get : vi . fn ( ) } ,
265+ arrayBuffer : vi . fn ( ( ) => new ArrayBuffer ( 0 ) )
266+ } ) ;
271267 } ) ;
272268
273- it ( "Block public unicast IPs if not whitelistet " , async ( ) => {
269+ it ( "Blocks domains not in whitelist " , async ( ) => {
274270 global . config = { cors : "allowWhitelist" , corsDomainWhitelist : [ ] } ;
275- mockLookup . mockResolvedValue ( [ { address : "93.184.216.34" , family : 4 } ] ) ;
276- expect ( await isPrivateTarget ( "http://example.com/api" ) ) . toBe ( true ) ;
271+ await cors ( { url : "/cors?url=http://example.com/api" } , response ) ;
272+ expect ( response . status ) . toHaveBeenCalledWith ( 403 ) ;
277273 } ) ;
278274
279- it ( "Allow public unicast IPs if whitelistet " , async ( ) => {
275+ it ( "Allows domains in whitelist " , async ( ) => {
280276 global . config = { cors : "allowWhitelist" , corsDomainWhitelist : [ "example.com" ] } ;
281- mockLookup . mockResolvedValue ( [ { address : "93.184.216.34" , family : 4 } ] ) ;
282- expect ( await isPrivateTarget ( "http://example.com/api" ) ) . toBe ( false ) ;
277+ await cors ( { url : "/cors?url=http://example.com/api" } , response ) ;
278+ expect ( response . status ) . not . toHaveBeenCalledWith ( 403 ) ;
283279 } ) ;
284280 } ) ;
285281} ) ;
0 commit comments