11import path from "path" ;
22import crypto from "crypto" ;
33import fetch from "node-fetch" ;
4+ import dns from "dns/promises" ;
5+ import net from "net" ;
46import { app , fs } from "../../../index.mjs" ;
57
68const CACHE_DIR = "./cache/proxy" ;
79const TTL = 1000 * 60 * 60 * 24 ;
10+ const MAX_BYTES = 10 * 1024 * 1024 ;
811
9- // delete all cache files after some time if age expired,
10- // because otherwise its gonna turn into a junk folder
1112setInterval ( ( ) => {
13+ if ( ! fs . existsSync ( CACHE_DIR ) ) return ;
14+
1215 const now = Date . now ( ) ;
1316 for ( const f of fs . readdirSync ( CACHE_DIR ) ) {
1417 if ( f . endsWith ( ".type" ) ) continue ;
18+
1519 const file = path . join ( CACHE_DIR , f ) ;
1620
1721 try {
1822 const age = now - fs . statSync ( file ) . mtimeMs ;
1923 if ( age > TTL ) {
2024 fs . unlinkSync ( file ) ;
21- const type = file + ".type" ;
22- if ( fs . existsSync ( type ) ) fs . unlinkSync ( type ) ;
25+ if ( fs . existsSync ( file + ".type" ) ) fs . unlinkSync ( file + ".type" ) ;
2326 }
2427 } catch { }
2528 }
2629} , 1000 * 60 * 30 ) ;
2730
31+ function isBlockedIp ( ip ) {
32+ if ( net . isIP ( ip ) === 4 ) {
33+ const p = ip . split ( "." ) . map ( Number ) ;
34+ if ( p [ 0 ] === 10 ) return true ;
35+ if ( p [ 0 ] === 127 ) return true ;
36+ if ( p [ 0 ] === 0 ) return true ;
37+ if ( p [ 0 ] === 169 && p [ 1 ] === 254 ) return true ;
38+ if ( p [ 0 ] === 172 && p [ 1 ] >= 16 && p [ 1 ] <= 31 ) return true ;
39+ if ( p [ 0 ] === 192 && p [ 1 ] === 168 ) return true ;
40+ return false ;
41+ }
42+
43+ if ( net . isIP ( ip ) === 6 ) {
44+ const v = ip . toLowerCase ( ) ;
45+ if ( v === "::1" || v === "::" ) return true ;
46+ if ( v . startsWith ( "fc" ) || v . startsWith ( "fd" ) ) return true ;
47+ if ( v . startsWith ( "fe80:" ) ) return true ;
48+ if ( v . startsWith ( "::ffff:" ) ) {
49+ const mapped = v . slice ( 7 ) ;
50+ if ( net . isIP ( mapped ) === 4 ) return isBlockedIp ( mapped ) ;
51+ return true ;
52+ }
53+ return false ;
54+ }
55+
56+ return true ;
57+ }
58+
59+ async function assertSafeHost ( hostname ) {
60+ if ( ! hostname ) throw new Error ( "Invalid host" ) ;
61+
62+ if ( net . isIP ( hostname ) ) {
63+ if ( isBlockedIp ( hostname ) ) throw new Error ( "Blocked host" ) ;
64+ return ;
65+ }
66+
67+ const records = await dns . lookup ( hostname , { all : true } ) ;
68+ if ( ! records . length ) throw new Error ( "DNS failed" ) ;
69+
70+ for ( const record of records ) {
71+ if ( isBlockedIp ( record . address ) ) throw new Error ( "Blocked host" ) ;
72+ }
73+ }
74+
75+ function normalizeType ( type ) {
76+ return String ( type || "" ) . split ( ";" ) [ 0 ] . trim ( ) . toLowerCase ( ) ;
77+ }
78+
79+ function isAllowedImageType ( type ) {
80+ if ( ! type . startsWith ( "image/" ) ) return false ;
81+ if ( type === "image/svg+xml" ) return false ;
82+ return true ;
83+ }
84+
2885app . get ( "/proxy" , async ( req , res ) => {
2986 const url = req . query . url ;
30- if ( ! url || ! / ^ h t t p s ? : \/ \/ / . test ( url ) ) return res . status ( 400 ) . send ( "Invalid URL" ) ;
87+ if ( ! url || typeof url !== "string" ) return res . status ( 400 ) . send ( "Invalid URL" ) ;
88+
89+ let parsed ;
90+ try {
91+ parsed = new URL ( url ) ;
92+ } catch {
93+ return res . status ( 400 ) . send ( "Invalid URL" ) ;
94+ }
95+
96+ if ( ! [ "http:" , "https:" ] . includes ( parsed . protocol ) ) {
97+ return res . status ( 400 ) . send ( "Invalid URL" ) ;
98+ }
99+
31100 if ( ! fs . existsSync ( CACHE_DIR ) ) fs . mkdirSync ( CACHE_DIR , { recursive : true } ) ;
32101
33- const hash = crypto . createHash ( "sha1" ) . update ( url ) . digest ( "hex" ) ;
102+ const hash = crypto . createHash ( "sha1" ) . update ( parsed . toString ( ) ) . digest ( "hex" ) ;
34103 const file = path . join ( CACHE_DIR , hash ) ;
35104 const typefile = file + ".type" ;
36105
37106 try {
38- if ( fs . existsSync ( file ) ) {
107+ await assertSafeHost ( parsed . hostname ) ;
108+
109+ if ( fs . existsSync ( file ) && fs . existsSync ( typefile ) ) {
39110 const age = Date . now ( ) - fs . statSync ( file ) . mtimeMs ;
111+
40112 if ( age < TTL ) {
41- const type = fs . existsSync ( typefile ) ? fs . readFileSync ( typefile , "utf8" ) : "application/octet-stream" ;
113+ const type = normalizeType ( fs . readFileSync ( typefile , "utf8" ) ) ;
114+ if ( ! isAllowedImageType ( type ) ) {
115+ try { fs . unlinkSync ( file ) ; } catch { }
116+ try { fs . unlinkSync ( typefile ) ; } catch { }
117+ return res . status ( 415 ) . send ( "Blocked content type" ) ;
118+ }
119+
42120 res . setHeader ( "Content-Type" , type ) ;
121+ res . setHeader ( "X-Content-Type-Options" , "nosniff" ) ;
43122 return fs . createReadStream ( file ) . pipe ( res ) ;
44- } else {
45- fs . unlinkSync ( file ) ;
46- if ( fs . existsSync ( typefile ) ) fs . unlinkSync ( typefile ) ;
47123 }
124+
125+ try { fs . unlinkSync ( file ) ; } catch { }
126+ try { fs . unlinkSync ( typefile ) ; } catch { }
48127 }
49128
50- const r = await fetch ( url , { timeout : 7000 } ) ;
129+ const r = await fetch ( parsed . toString ( ) , {
130+ timeout : 7000 ,
131+ redirect : "error" ,
132+ size : MAX_BYTES
133+ } ) ;
134+
51135 if ( ! r . ok ) return res . status ( 500 ) . send ( "Fetch failed" ) ;
52136
53- const type = r . headers . get ( "content-type" ) || "application/octet-stream" ;
137+ const type = normalizeType ( r . headers . get ( "content-type" ) ) ;
138+ if ( ! isAllowedImageType ( type ) ) {
139+ return res . status ( 415 ) . send ( "Blocked content type" ) ;
140+ }
141+
54142 const ws = fs . createWriteStream ( file ) ;
55143 await new Promise ( ( resolve , reject ) => {
56144 r . body . pipe ( ws ) ;
57145 r . body . on ( "error" , reject ) ;
146+ ws . on ( "error" , reject ) ;
58147 ws . on ( "finish" , resolve ) ;
59148 } ) ;
149+
60150 fs . writeFileSync ( typefile , type ) ;
151+
61152 res . setHeader ( "Content-Type" , type ) ;
153+ res . setHeader ( "X-Content-Type-Options" , "nosniff" ) ;
62154 fs . createReadStream ( file ) . pipe ( res ) ;
63155 } catch {
64156 res . status ( 500 ) . send ( "Proxy error" ) ;
65157 }
66158} ) ;
67159
68- export default ( io ) => ( socket ) => { } ;
160+ export default ( io ) => ( socket ) => { } ;
0 commit comments