@msal95/fileguard 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +530 -0
- package/index.js +2 -0
- package/package.json +122 -0
- package/src/adapters/express.js +86 -0
- package/src/adapters/fastify.js +105 -0
- package/src/adapters/index.js +3 -0
- package/src/adapters/nextjs.js +74 -0
- package/src/audit/logger.js +32 -0
- package/src/core/rateLimiter.js +55 -0
- package/src/core/sanitizer.js +62 -0
- package/src/core/validator.js +163 -0
- package/src/errors/UploadError.js +43 -0
- package/src/index.js +65 -0
- package/src/react/DropZone.jsx +146 -0
- package/src/react/FilePreview.jsx +152 -0
- package/src/react/ProgressBar.jsx +82 -0
- package/src/react/UploadButton.jsx +123 -0
- package/src/react/index.js +4 -0
- package/src/scanners/clamav.js +50 -0
- package/src/scanners/magicBytes.js +62 -0
- package/src/scanners/polyglot.js +58 -0
- package/src/scanners/virustotal.js +85 -0
- package/src/scanners/zipBomb.js +85 -0
- package/src/storage/cloudinary.js +56 -0
- package/src/storage/index.js +27 -0
- package/src/storage/local.js +39 -0
- package/src/storage/s3.js +62 -0
- package/types/index.d.ts +380 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { failResult } from '../errors/UploadError.js'
|
|
2
|
+
|
|
3
|
+
const VT_BASE = 'https://www.virustotal.com/api/v3'
|
|
4
|
+
|
|
5
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Scan a buffer with the VirusTotal API v3.
|
|
9
|
+
*
|
|
10
|
+
* Behaviour when scanning is unavailable:
|
|
11
|
+
* - apiKey missing or empty → warn + skip (upload proceeds)
|
|
12
|
+
* - upload or poll failure → warn + skip (upload proceeds)
|
|
13
|
+
* - analysis never completes → warn + skip (upload proceeds)
|
|
14
|
+
*
|
|
15
|
+
* @param {Buffer} buffer
|
|
16
|
+
* @param {{ apiKey?: string, pollIntervalMs?: number, maxPolls?: number }} [config]
|
|
17
|
+
* @returns {Promise<{ success: true, skipped?: true } | { success: false, error: string, message: string }>}
|
|
18
|
+
*/
|
|
19
|
+
export async function scanWithVirusTotal(buffer, config = {}) {
|
|
20
|
+
const { apiKey, pollIntervalMs = 5_000, maxPolls = 3 } = config
|
|
21
|
+
|
|
22
|
+
if (!apiKey) {
|
|
23
|
+
console.warn('[fileguard] VirusTotal scan skipped: no apiKey configured.')
|
|
24
|
+
return { success: true, skipped: true }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const headers = { 'x-apikey': apiKey }
|
|
28
|
+
|
|
29
|
+
// ── Upload ──────────────────────────────────────────────────────────────────
|
|
30
|
+
let analysisId
|
|
31
|
+
try {
|
|
32
|
+
const formData = new FormData()
|
|
33
|
+
formData.append('file', new Blob([buffer]), 'upload')
|
|
34
|
+
|
|
35
|
+
const uploadRes = await fetch(`${VT_BASE}/files`, {
|
|
36
|
+
method: 'POST',
|
|
37
|
+
headers,
|
|
38
|
+
body: formData,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
if (!uploadRes.ok) {
|
|
42
|
+
const text = await uploadRes.text().catch(() => uploadRes.status)
|
|
43
|
+
console.warn(`[fileguard] VirusTotal upload failed (${uploadRes.status}): ${text}. Skipping scan.`)
|
|
44
|
+
return { success: true, skipped: true }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const { data } = await uploadRes.json()
|
|
48
|
+
analysisId = data.id
|
|
49
|
+
} catch (err) {
|
|
50
|
+
console.warn(`[fileguard] VirusTotal upload error: ${err.message}. Skipping scan.`)
|
|
51
|
+
return { success: true, skipped: true }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Poll for results ────────────────────────────────────────────────────────
|
|
55
|
+
for (let i = 0; i < maxPolls; i++) {
|
|
56
|
+
try {
|
|
57
|
+
const res = await fetch(`${VT_BASE}/analyses/${analysisId}`, { headers })
|
|
58
|
+
|
|
59
|
+
if (res.ok) {
|
|
60
|
+
const { data } = await res.json()
|
|
61
|
+
const status = data?.attributes?.status
|
|
62
|
+
|
|
63
|
+
if (status === 'completed') {
|
|
64
|
+
const { malicious = 0, suspicious = 0 } = data.attributes.stats ?? {}
|
|
65
|
+
if (malicious > 0 || suspicious > 0) {
|
|
66
|
+
return failResult(
|
|
67
|
+
'VIRUS_DETECTED',
|
|
68
|
+
`VirusTotal flagged this file (malicious: ${malicious}, suspicious: ${suspicious})`
|
|
69
|
+
)
|
|
70
|
+
}
|
|
71
|
+
return { success: true }
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (err) {
|
|
75
|
+
console.warn(`[fileguard] VirusTotal poll error: ${err.message}. Skipping scan.`)
|
|
76
|
+
return { success: true, skipped: true }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Wait before next poll (skip sleep on the final iteration)
|
|
80
|
+
if (i < maxPolls - 1) await sleep(pollIntervalMs)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
console.warn('[fileguard] VirusTotal analysis did not complete in time. Skipping scan.')
|
|
84
|
+
return { success: true, skipped: true }
|
|
85
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { failResult } from '../errors/UploadError.js'
|
|
2
|
+
|
|
3
|
+
const EOCD_SIG = 0x06054b50
|
|
4
|
+
const CD_SIG = 0x02014b50
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Locate the End of Central Directory record by scanning backwards.
|
|
8
|
+
* @param {Buffer} buf
|
|
9
|
+
* @returns {number} byte offset, or -1 if not found
|
|
10
|
+
*/
|
|
11
|
+
function findEOCD(buf) {
|
|
12
|
+
for (let i = buf.length - 22; i >= 0; i--) {
|
|
13
|
+
if (buf.readUInt32LE(i) === EOCD_SIG) return i
|
|
14
|
+
}
|
|
15
|
+
return -1
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Walk the central directory and sum compressed/uncompressed sizes.
|
|
20
|
+
* @param {Buffer} buf
|
|
21
|
+
* @param {number} cdOffset
|
|
22
|
+
* @param {number} cdSize
|
|
23
|
+
* @returns {{ totalCompressed: number, totalUncompressed: number, fileCount: number }}
|
|
24
|
+
*/
|
|
25
|
+
function parseCentralDirectory(buf, cdOffset, cdSize) {
|
|
26
|
+
let totalCompressed = 0
|
|
27
|
+
let totalUncompressed = 0
|
|
28
|
+
let fileCount = 0
|
|
29
|
+
let offset = cdOffset
|
|
30
|
+
|
|
31
|
+
while (offset < cdOffset + cdSize && offset + 46 <= buf.length) {
|
|
32
|
+
if (buf.readUInt32LE(offset) !== CD_SIG) break
|
|
33
|
+
|
|
34
|
+
const compressedSize = buf.readUInt32LE(offset + 20)
|
|
35
|
+
const uncompressedSize = buf.readUInt32LE(offset + 24)
|
|
36
|
+
const filenameLen = buf.readUInt16LE(offset + 28)
|
|
37
|
+
const extraLen = buf.readUInt16LE(offset + 30)
|
|
38
|
+
const commentLen = buf.readUInt16LE(offset + 32)
|
|
39
|
+
|
|
40
|
+
totalCompressed += compressedSize
|
|
41
|
+
totalUncompressed += uncompressedSize
|
|
42
|
+
fileCount++
|
|
43
|
+
|
|
44
|
+
offset += 46 + filenameLen + extraLen + commentLen
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return { totalCompressed, totalUncompressed, fileCount }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Check a ZIP buffer for zip bomb patterns.
|
|
52
|
+
* Rejects if compression ratio > ratioThreshold or file count > maxFiles.
|
|
53
|
+
*
|
|
54
|
+
* @param {Buffer} buffer
|
|
55
|
+
* @param {{ ratioThreshold?: number, maxFiles?: number }} [options]
|
|
56
|
+
* @returns {{ success: true } | { success: false, error: string, message: string }}
|
|
57
|
+
*/
|
|
58
|
+
export function checkZipBomb(buffer, options = {}) {
|
|
59
|
+
const { ratioThreshold = 100, maxFiles = 1000 } = options
|
|
60
|
+
|
|
61
|
+
const eocdOffset = findEOCD(buffer)
|
|
62
|
+
if (eocdOffset === -1) {
|
|
63
|
+
return failResult('ZIP_BOMB_DETECTED', 'Invalid ZIP structure: no end-of-central-directory record found')
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const cdSize = buffer.readUInt32LE(eocdOffset + 12)
|
|
67
|
+
const cdOffset = buffer.readUInt32LE(eocdOffset + 16)
|
|
68
|
+
|
|
69
|
+
if (cdOffset + cdSize > buffer.length) {
|
|
70
|
+
return failResult('ZIP_BOMB_DETECTED', 'Invalid ZIP structure: central directory out of bounds')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const { totalCompressed, totalUncompressed, fileCount } = parseCentralDirectory(buffer, cdOffset, cdSize)
|
|
74
|
+
|
|
75
|
+
if (fileCount > maxFiles) {
|
|
76
|
+
return failResult('ZIP_BOMB_DETECTED', `ZIP contains ${fileCount} files, exceeding the limit of ${maxFiles}`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (totalCompressed > 0 && totalUncompressed / totalCompressed > ratioThreshold) {
|
|
80
|
+
const ratio = (totalUncompressed / totalCompressed).toFixed(1)
|
|
81
|
+
return failResult('ZIP_BOMB_DETECTED', `Compression ratio ${ratio}x exceeds the limit of ${ratioThreshold}x`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { success: true }
|
|
85
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { failResult, successResult } from '../errors/UploadError.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Upload a file to Cloudinary.
|
|
5
|
+
* Requires the optional peer dependency: cloudinary
|
|
6
|
+
*
|
|
7
|
+
* @param {{ buffer: Buffer, sanitizedFilename: string, size: number, mimeType: string }} file
|
|
8
|
+
* @param {{ cloudName: string, apiKey: string, apiSecret: string, resourceType?: string, folder?: string }} config
|
|
9
|
+
* @returns {Promise<{ success: true, data: object } | { success: false, error: string, message: string }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function cloudinaryStore(file, config = {}) {
|
|
12
|
+
let cloudinary
|
|
13
|
+
try {
|
|
14
|
+
const mod = await import('cloudinary')
|
|
15
|
+
cloudinary = mod.v2
|
|
16
|
+
} catch {
|
|
17
|
+
return failResult(
|
|
18
|
+
'STORAGE_ERROR',
|
|
19
|
+
'Cloudinary storage requires the cloudinary package. Install it with: npm install cloudinary'
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const { cloudName, apiKey, apiSecret, resourceType = 'auto', folder } = config
|
|
24
|
+
if (!cloudName || !apiKey || !apiSecret) {
|
|
25
|
+
return failResult('STORAGE_ERROR', 'Cloudinary storage requires cloudName, apiKey, and apiSecret')
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
cloudinary.config({ cloud_name: cloudName, api_key: apiKey, api_secret: apiSecret })
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
const uploadOptions = {
|
|
32
|
+
resource_type: resourceType,
|
|
33
|
+
use_filename: true,
|
|
34
|
+
unique_filename: false,
|
|
35
|
+
}
|
|
36
|
+
if (folder) uploadOptions.folder = folder
|
|
37
|
+
|
|
38
|
+
const result = await new Promise((resolve, reject) => {
|
|
39
|
+
const stream = cloudinary.uploader.upload_stream(uploadOptions, (err, res) => {
|
|
40
|
+
if (err) reject(err)
|
|
41
|
+
else resolve(res)
|
|
42
|
+
})
|
|
43
|
+
stream.end(file.buffer)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
return successResult({
|
|
47
|
+
url: result.secure_url,
|
|
48
|
+
filename: file.sanitizedFilename,
|
|
49
|
+
size: file.size,
|
|
50
|
+
mimeType: file.mimeType,
|
|
51
|
+
storage: 'cloudinary',
|
|
52
|
+
})
|
|
53
|
+
} catch (err) {
|
|
54
|
+
return failResult('STORAGE_ERROR', `Cloudinary upload failed: ${err.message}`)
|
|
55
|
+
}
|
|
56
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export { localStore } from './local.js'
|
|
2
|
+
export { s3Store } from './s3.js'
|
|
3
|
+
export { cloudinaryStore } from './cloudinary.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Return the storage adapter function for the given storage type string.
|
|
7
|
+
* @param {'local'|'s3'|'cloudinary'} type
|
|
8
|
+
* @returns {Function}
|
|
9
|
+
*/
|
|
10
|
+
export async function getStorageAdapter(type) {
|
|
11
|
+
switch (type) {
|
|
12
|
+
case 'local': {
|
|
13
|
+
const { localStore } = await import('./local.js')
|
|
14
|
+
return localStore
|
|
15
|
+
}
|
|
16
|
+
case 's3': {
|
|
17
|
+
const { s3Store } = await import('./s3.js')
|
|
18
|
+
return s3Store
|
|
19
|
+
}
|
|
20
|
+
case 'cloudinary': {
|
|
21
|
+
const { cloudinaryStore } = await import('./cloudinary.js')
|
|
22
|
+
return cloudinaryStore
|
|
23
|
+
}
|
|
24
|
+
default:
|
|
25
|
+
throw new Error(`Unknown storage adapter: "${type}". Valid values: local, s3, cloudinary`)
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { writeFile, mkdir } from 'fs/promises'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { v4 as uuidv4 } from 'uuid'
|
|
4
|
+
import { failResult, successResult } from '../errors/UploadError.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Store a file to the local filesystem.
|
|
8
|
+
*
|
|
9
|
+
* @param {{ buffer: Buffer, sanitizedFilename?: string, filename: string, size: number, mimeType: string }} file
|
|
10
|
+
* @param {{ localPath?: string }} [config]
|
|
11
|
+
* @returns {Promise<{ success: true, data: object } | { success: false, error: string, message: string }>}
|
|
12
|
+
*/
|
|
13
|
+
export async function localStore(file, config = {}) {
|
|
14
|
+
const { localPath = './uploads' } = config
|
|
15
|
+
const ext = path.extname(file.filename).toLowerCase()
|
|
16
|
+
const filename = file.sanitizedFilename ?? `${uuidv4()}${ext}`
|
|
17
|
+
const destDir = path.resolve(localPath)
|
|
18
|
+
const destPath = path.join(destDir, filename)
|
|
19
|
+
|
|
20
|
+
// Guard against path traversal: sanitizedFilename must stay under destDir
|
|
21
|
+
if (!destPath.startsWith(destDir + path.sep) && destPath !== destDir) {
|
|
22
|
+
return failResult('STORAGE_ERROR', 'Path traversal detected in filename')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
try {
|
|
26
|
+
await mkdir(destDir, { recursive: true })
|
|
27
|
+
await writeFile(destPath, file.buffer)
|
|
28
|
+
|
|
29
|
+
return successResult({
|
|
30
|
+
url: destPath,
|
|
31
|
+
filename,
|
|
32
|
+
size: file.size,
|
|
33
|
+
mimeType: file.mimeType,
|
|
34
|
+
storage: 'local',
|
|
35
|
+
})
|
|
36
|
+
} catch (err) {
|
|
37
|
+
return failResult('STORAGE_ERROR', `Local storage failed: ${err.message}`)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { failResult, successResult } from '../errors/UploadError.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Store a file to AWS S3 (or an S3-compatible service).
|
|
5
|
+
* Requires the optional peer dependency: @aws-sdk/client-s3
|
|
6
|
+
*
|
|
7
|
+
* @param {{ buffer: Buffer, sanitizedFilename: string, size: number, mimeType: string }} file
|
|
8
|
+
* @param {{ bucket: string, region?: string, prefix?: string, endpoint?: string }} config
|
|
9
|
+
* @returns {Promise<{ success: true, data: object } | { success: false, error: string, message: string }>}
|
|
10
|
+
*/
|
|
11
|
+
export async function s3Store(file, config = {}) {
|
|
12
|
+
let S3Client, PutObjectCommand
|
|
13
|
+
try {
|
|
14
|
+
const sdk = await import('@aws-sdk/client-s3')
|
|
15
|
+
S3Client = sdk.S3Client
|
|
16
|
+
PutObjectCommand = sdk.PutObjectCommand
|
|
17
|
+
} catch {
|
|
18
|
+
return failResult(
|
|
19
|
+
'STORAGE_ERROR',
|
|
20
|
+
'S3 storage requires @aws-sdk/client-s3. Install it with: npm install @aws-sdk/client-s3'
|
|
21
|
+
)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const { bucket, region = 'us-east-1', prefix = '', endpoint } = config
|
|
25
|
+
if (!bucket) {
|
|
26
|
+
return failResult('STORAGE_ERROR', 'S3 storage requires a bucket name in config')
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const filename = file.sanitizedFilename
|
|
30
|
+
const key = prefix ? `${prefix}/${filename}` : filename
|
|
31
|
+
|
|
32
|
+
const clientOptions = { region }
|
|
33
|
+
if (endpoint) clientOptions.endpoint = endpoint
|
|
34
|
+
|
|
35
|
+
const client = new S3Client(clientOptions)
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
await client.send(
|
|
39
|
+
new PutObjectCommand({
|
|
40
|
+
Bucket: bucket,
|
|
41
|
+
Key: key,
|
|
42
|
+
Body: file.buffer,
|
|
43
|
+
ContentType: file.mimeType,
|
|
44
|
+
ContentLength: file.size,
|
|
45
|
+
})
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
const baseUrl = endpoint
|
|
49
|
+
? `${endpoint.replace(/\/$/, '')}/${bucket}/${key}`
|
|
50
|
+
: `https://${bucket}.s3.${region}.amazonaws.com/${key}`
|
|
51
|
+
|
|
52
|
+
return successResult({
|
|
53
|
+
url: baseUrl,
|
|
54
|
+
filename,
|
|
55
|
+
size: file.size,
|
|
56
|
+
mimeType: file.mimeType,
|
|
57
|
+
storage: 's3',
|
|
58
|
+
})
|
|
59
|
+
} catch (err) {
|
|
60
|
+
return failResult('STORAGE_ERROR', `S3 upload failed: ${err.message}`)
|
|
61
|
+
}
|
|
62
|
+
}
|