@@ -67,15 +67,23 @@ app.use((req, res, next) => {
6767} ) ;
6868app . use ( cookieParser ( ) ) ;
6969app . use ( requireAuth ) ;
70- app . use ( "/uploads" , express . static ( UPLOADS_DIR ) ) ;
70+ app . use ( "/uploads" , ( req , res , next ) => {
71+ const ext = req . path . split ( '.' ) . pop ( ) . toLowerCase ( ) ;
72+ if ( FORCE_DOWNLOAD_EXTENSIONS . has ( ext ) ) {
73+ const filename = path . basename ( req . path ) ;
74+ res . setHeader ( 'Content-Disposition' , `attachment; filename="${ filename } "` ) ;
75+ }
76+ next ( ) ;
77+ } , express . static ( UPLOADS_DIR ) ) ;
7178app . use ( express . static ( CLIENT_DIR ) ) ;
7279
7380const storage = multer . diskStorage ( {
7481 destination : ( req , file , cb ) => {
7582 cb ( null , UPLOADS_DIR ) ;
7683 } ,
7784 filename : ( req , file , cb ) => {
78- const unique = Date . now ( ) + "-" + file . originalname ;
85+ const safe = sanitiseFilename ( file . originalname ) ;
86+ const unique = Date . now ( ) + "-" + safe ;
7987 cb ( null , unique ) ;
8088 } ,
8189} ) ;
@@ -85,6 +93,64 @@ const upload = multer({
8593 limits : { fileSize : config . storage . maxFileSize } ,
8694} ) ;
8795
96+ // FILE SECURITY
97+
98+ // Extensions that must never be rendered inline by a browser.
99+ // These are served with Content-Disposition: attachment always.
100+ const FORCE_DOWNLOAD_EXTENSIONS = new Set ( [
101+ 'svg' , 'html' , 'htm' , 'xml' , 'xhtml' , 'js' , 'mjs' , 'php' ,
102+ 'sh' , 'bash' , 'py' , 'rb' , 'pl' , 'ps1' , 'bat' , 'cmd' , 'exe' ,
103+ 'dll' , 'jar' , 'vbs' , 'ws' , 'hta'
104+ ] ) ;
105+
106+ // Magic number signatures — first bytes that identify real file types.
107+ // Key = expected extension group, value = array of valid byte signatures.
108+ const MAGIC_NUMBERS = {
109+ jpg : [ Buffer . from ( [ 0xFF , 0xD8 , 0xFF ] ) ] ,
110+ png : [ Buffer . from ( [ 0x89 , 0x50 , 0x4E , 0x47 ] ) ] ,
111+ gif : [ Buffer . from ( [ 0x47 , 0x49 , 0x46 , 0x38 ] ) ] ,
112+ webp : [ Buffer . from ( [ 0x52 , 0x49 , 0x46 , 0x46 ] ) ] ,
113+ pdf : [ Buffer . from ( [ 0x25 , 0x50 , 0x44 , 0x46 ] ) ] ,
114+ zip : [ Buffer . from ( [ 0x50 , 0x4B , 0x03 , 0x04 ] ) , Buffer . from ( [ 0x50 , 0x4B , 0x05 , 0x06 ] ) ] ,
115+ mp4 : [ Buffer . from ( [ 0x00 , 0x00 , 0x00 , 0x18 ] ) , Buffer . from ( [ 0x00 , 0x00 , 0x00 , 0x20 ] ) ] ,
116+ } ;
117+
118+ // Extension → magic group mapping
119+ const EXT_TO_MAGIC = {
120+ jpg : 'jpg' , jpeg : 'jpg' ,
121+ png : 'png' ,
122+ gif : 'gif' ,
123+ webp : 'webp' ,
124+ pdf : 'pdf' ,
125+ zip : 'zip' ,
126+ mp4 : 'mp4' ,
127+ } ;
128+
129+ function sanitiseFilename ( name ) {
130+ return name
131+ . replace ( / [ / \\ ? % * : | " < > \x00 ] / g, '_' ) // strip path separators and dangerous chars
132+ . replace ( / \. { 2 , } / g, '.' ) // collapse .. sequences
133+ . trim ( )
134+ . slice ( 0 , 255 ) ; // max filename length
135+ }
136+
137+ function checkMagicNumber ( filePath , ext ) {
138+ const group = EXT_TO_MAGIC [ ext . toLowerCase ( ) ] ;
139+ if ( ! group ) return true ; // no check defined for this type — allow through
140+
141+ const signatures = MAGIC_NUMBERS [ group ] ;
142+ if ( ! signatures ) return true ;
143+
144+ try {
145+ const fd = fs . openSync ( filePath , 'r' ) ;
146+ const buf = Buffer . alloc ( 8 ) ;
147+ fs . readSync ( fd , buf , 0 , 8 , 0 ) ;
148+ fs . closeSync ( fd ) ;
149+ return signatures . some ( sig => buf . slice ( 0 , sig . length ) . equals ( sig ) ) ;
150+ } catch ( e ) {
151+ return false ; // can't read → reject
152+ }
153+ }
88154
89155function hexToHsl ( hex ) {
90156 let r = parseInt ( hex . slice ( 1 , 3 ) , 16 ) / 255 ;
@@ -332,6 +398,14 @@ app.post("/upload", dropLimiter, upload.single("file"), (req, res) => {
332398 return res . status ( 400 ) . json ( { error : "No file received" } ) ;
333399 }
334400
401+ // Magic number check — verify file content matches its extension
402+ const ext = req . file . originalname . split ( '.' ) . pop ( ) . toLowerCase ( ) ;
403+ const filePath = path . join ( UPLOADS_DIR , req . file . filename ) ;
404+ if ( ! checkMagicNumber ( filePath , ext ) ) {
405+ fs . unlinkSync ( filePath ) ; // delete the suspicious file immediately
406+ return res . status ( 400 ) . json ( { error : "File content does not match its extension" } ) ;
407+ }
408+
335409 const { channel, uploader } = req . body ;
336410
337411 const item = {
0 commit comments