release: v4.8.11 file transfer reliability fix
CodeQL Analysis / Analyze CodeQL (push) Has been cancelled
Deploy Application / deploy (push) Has been cancelled
Mirror to Codeberg / mirror (push) Has been cancelled
Mirror to PrivacyGuides / mirror (push) Has been cancelled

fix(file-transfer): size chunks under the 64KB SCTP message limit

Each 64KB chunk became a ~87KB AES-GCM+Base64 file_chunk message,
exceeding WebRTC's 64KB SCTP message-size floor. The consent handshake
(small messages) succeeded, but no chunk was ever delivered on Safari
and cross-browser connections whose SDP omits a=max-message-size, so
files never transferred. Send chunk size is now 16KB (~22KB on the
wire); inbound chunks up to 64KB stay accepted for backward compat.

fix(file-transfer): make MIME advisory, drive validation by extension

The client-supplied MIME type is easily spoofed and varies across
browsers/OSes, yet was a hard gate: files with an empty MIME or a
cross-OS variant (application/x-zip-compressed, image/jpg) were wrongly
rejected. Extension allow-list plus BLOCKED_EXTENSIONS is now the
boundary; a blatantly foreign MIME on a safe extension is still rejected
and per-type size limits still apply.
This commit is contained in:
lockbitchat
2026-06-16 18:24:29 -04:00
parent 9244250835
commit be1d02f1f7
10 changed files with 133 additions and 60 deletions
+12
View File
@@ -1,5 +1,17 @@
# Changelog # Changelog
## v4.8.11 — File transfer reliability fix
Fixes file transfers that silently failed to reach the peer, and relaxes the overly strict file-type check that rejected legitimate files.
### Fixed
- File chunks are now sized so the on-the-wire message stays under the 64 KB SCTP message-size limit enforced by WebRTC. Previously each 64 KB chunk became a ~87 KB encrypted+Base64 message that exceeded this limit, so the consent handshake succeeded but no data was ever delivered — most visibly on Safari and cross-browser connections whose SDP omits `a=max-message-size`. The send chunk size is now 16 KB (~22 KB on the wire); inbound chunks up to 64 KB are still accepted for backward compatibility.
### Changed
- File-type validation is now driven by the extension allow-list, with the (client-supplied, easily spoofed) MIME type treated as an advisory signal. Files with a missing MIME type or a cross-OS MIME variant (e.g. `application/x-zip-compressed` for `.zip`, `image/jpg` for `.jpg`) are no longer rejected. Blocked executable/script extensions, a blatantly foreign MIME on a safe extension, and per-type size limits are still enforced.
## v4.8.10 — User-configurable STUN/TURN servers ## v4.8.10 — User-configurable STUN/TURN servers
Adds optional, advanced control over WebRTC connectivity for power and privacy-focused users. Public servers remain the zero-config default. Adds optional, advanced control over WebRTC connectivity for power and privacy-focused users. Public servers remain the zero-config default.
+7 -2
View File
@@ -1,4 +1,4 @@
# SecureBit.chat v4.8.10 # SecureBit.chat v4.8.11
SecureBit.chat is a browser-based peer-to-peer chat application built on WebRTC and Web Crypto APIs. It is designed for direct encrypted communication, explicit peer verification, and a small operational footprint without account registration or server-side message storage. SecureBit.chat is a browser-based peer-to-peer chat application built on WebRTC and Web Crypto APIs. It is designed for direct encrypted communication, explicit peer verification, and a small operational footprint without account registration or server-side message storage.
@@ -15,7 +15,12 @@ SecureBit.chat uses:
A session is not treated as verified until both peers complete the interactive SAS flow. Each user must compare the displayed code with the peer through an out-of-band channel and enter the matching code manually. Three failed SAS attempts terminate the session. A session is not treated as verified until both peers complete the interactive SAS flow. Each user must compare the displayed code with the peer through an out-of-band channel and enter the matching code manually. Three failed SAS attempts terminate the session.
## Highlights in v4.8.10 ## Highlights in v4.8.11
- Fixed: file transfers that completed the consent handshake but never delivered any data. Chunks are now sized to stay under WebRTC's 64 KB SCTP message limit (most visible on Safari and cross-browser connections).
- File-type validation is now extension-driven; the easily-spoofed MIME type is advisory, so files with a missing or cross-OS MIME variant are no longer wrongly rejected. Blocked executable/script extensions and size limits are still enforced.
Earlier in v4.8.10:
- New: users can configure their own STUN/TURN servers under "Advanced network settings" (header gear or the connection-creation screen). Input is allowlist-validated, optionally saved encrypted on-device, and a built-in "Test servers" check reports STUN/TURN reachability. - New: users can configure their own STUN/TURN servers under "Advanced network settings" (header gear or the connection-creation screen). Input is allowlist-validated, optionally saved encrypted on-device, and a built-in "Test servers" check reports STUN/TURN reachability.
- Relay-only privacy mode moved into the advanced settings panel; the standalone start-screen toggle was removed. - Relay-only privacy mode moved into the advanced settings panel; the standalone start-screen toggle was removed.
+29 -17
View File
@@ -4413,7 +4413,8 @@ var EnhancedSecureFileTransfer = class {
this.rateLimiter = new RateLimiter(10, 6e4); this.rateLimiter = new RateLimiter(10, 6e4);
this.signingKey = null; this.signingKey = null;
this.verificationKey = null; this.verificationKey = null;
this.CHUNK_SIZE = 64 * 1024; this.CHUNK_SIZE = 16 * 1024;
this.MAX_RECEIVE_CHUNK_SIZE = 64 * 1024;
this.MAX_FILE_SIZE = 100 * 1024 * 1024; this.MAX_FILE_SIZE = 100 * 1024 * 1024;
this.MAX_CONCURRENT_TRANSFERS = 3; this.MAX_CONCURRENT_TRANSFERS = 3;
this.CHUNK_TIMEOUT = 3e4; this.CHUNK_TIMEOUT = 3e4;
@@ -4421,14 +4422,14 @@ var EnhancedSecureFileTransfer = class {
this.FILE_TYPE_RESTRICTIONS = { this.FILE_TYPE_RESTRICTIONS = {
pdf: { pdf: {
extensions: [".pdf"], extensions: [".pdf"],
mimeTypes: ["application/pdf"], mimeTypes: ["application/pdf", "application/x-pdf", "application/acrobat"],
maxSize: 50 * 1024 * 1024, maxSize: 50 * 1024 * 1024,
category: "PDF", category: "PDF",
description: "PDF" description: "PDF"
}, },
text: { text: {
extensions: [".txt"], extensions: [".txt"],
mimeTypes: ["text/plain"], mimeTypes: ["text/plain", "application/txt"],
maxSize: 10 * 1024 * 1024, maxSize: 10 * 1024 * 1024,
category: "Plain text", category: "Plain text",
description: "TXT" description: "TXT"
@@ -4437,11 +4438,15 @@ var EnhancedSecureFileTransfer = class {
extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico"], extensions: [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico"],
mimeTypes: [ mimeTypes: [
"image/jpeg", "image/jpeg",
"image/jpg",
"image/pjpeg",
"image/png", "image/png",
"image/gif", "image/gif",
"image/webp", "image/webp",
"image/bmp", "image/bmp",
"image/x-icon" "image/x-windows-bmp",
"image/x-icon",
"image/vnd.microsoft.icon"
], ],
maxSize: 25 * 1024 * 1024, maxSize: 25 * 1024 * 1024,
// 25 MB // 25 MB
@@ -4450,7 +4455,12 @@ var EnhancedSecureFileTransfer = class {
}, },
archives: { archives: {
extensions: [".zip"], extensions: [".zip"],
mimeTypes: ["application/zip"], mimeTypes: [
"application/zip",
"application/x-zip",
"application/x-zip-compressed",
"multipart/x-zip"
],
maxSize: 100 * 1024 * 1024, maxSize: 100 * 1024 * 1024,
// 100 MB // 100 MB
category: "Archives", category: "Archives",
@@ -4473,6 +4483,11 @@ var EnhancedSecureFileTransfer = class {
".html", ".html",
".svg" ".svg"
]); ]);
this._genericMimeTypes = /* @__PURE__ */ new Set(["application/octet-stream", "application/binary"]);
this._allowedMimeTypes = /* @__PURE__ */ new Set();
for (const typeConfig of Object.values(this.FILE_TYPE_RESTRICTIONS)) {
for (const mime of typeConfig.mimeTypes) this._allowedMimeTypes.add(mime);
}
this.activeTransfers = /* @__PURE__ */ new Map(); this.activeTransfers = /* @__PURE__ */ new Map();
this.receivingTransfers = /* @__PURE__ */ new Map(); this.receivingTransfers = /* @__PURE__ */ new Map();
this.pendingIncomingTransfers = /* @__PURE__ */ new Map(); this.pendingIncomingTransfers = /* @__PURE__ */ new Map();
@@ -4503,14 +4518,17 @@ var EnhancedSecureFileTransfer = class {
const mimeType = String(file?.type || "").toLowerCase(); const mimeType = String(file?.type || "").toLowerCase();
for (const [typeKey, typeConfig] of Object.entries(this.FILE_TYPE_RESTRICTIONS)) { for (const [typeKey, typeConfig] of Object.entries(this.FILE_TYPE_RESTRICTIONS)) {
const extensionAllowed = typeConfig.extensions.includes(fileExtension); const extensionAllowed = typeConfig.extensions.includes(fileExtension);
const mimeAllowed = typeConfig.mimeTypes.includes(mimeType); if (!extensionAllowed) continue;
if (extensionAllowed && mimeAllowed) { const mimeAcceptable = !mimeType || this._genericMimeTypes.has(mimeType) || this._allowedMimeTypes.has(mimeType);
if (mimeAcceptable) {
return { return {
type: typeKey, type: typeKey,
category: typeConfig.category, category: typeConfig.category,
description: typeConfig.description, description: typeConfig.description,
maxSize: typeConfig.maxSize, maxSize: typeConfig.maxSize,
allowed: true allowed: true,
extension: fileExtension,
mimeType
}; };
} }
} }
@@ -4531,20 +4549,14 @@ var EnhancedSecureFileTransfer = class {
const lowerName = fileName.toLowerCase(); const lowerName = fileName.toLowerCase();
const extensionIndex = lowerName.lastIndexOf("."); const extensionIndex = lowerName.lastIndexOf(".");
const fileExtension = extensionIndex >= 0 ? lowerName.substring(extensionIndex) : ""; const fileExtension = extensionIndex >= 0 ? lowerName.substring(extensionIndex) : "";
const mimeType = String(file?.type || "").toLowerCase();
if (this.BLOCKED_EXTENSIONS.has(fileExtension)) { if (this.BLOCKED_EXTENSIONS.has(fileExtension)) {
errors.push(`File rejected: ${fileExtension} files are not allowed for security reasons.`); errors.push(`File rejected: ${fileExtension} files are not allowed for security reasons.`);
} }
if (!mimeType) {
errors.push("File rejected: missing MIME type is unsafe.");
}
if (file.size > fileType.maxSize) { if (file.size > fileType.maxSize) {
errors.push(`File size (${this.formatFileSize(file.size)}) exceeds maximum allowed for ${fileType.category} (${this.formatFileSize(fileType.maxSize)})`); errors.push(`File size (${this.formatFileSize(file.size)}) exceeds maximum allowed for ${fileType.category} (${this.formatFileSize(fileType.maxSize)})`);
} }
if (!fileType.allowed) { if (!fileType.allowed && !this.BLOCKED_EXTENSIONS.has(fileExtension)) {
if (mimeType && !this.BLOCKED_EXTENSIONS.has(fileExtension)) { errors.push(`File rejected: unsupported file type. Supported types: ${fileType.description}`);
errors.push(`File rejected: extension and MIME type must match an allowed type. Supported types: ${fileType.description}`);
}
} }
if (file.size > this.MAX_FILE_SIZE) { if (file.size > this.MAX_FILE_SIZE) {
errors.push(`File size (${this.formatFileSize(file.size)}) exceeds general limit (${this.formatFileSize(this.MAX_FILE_SIZE)})`); errors.push(`File size (${this.formatFileSize(file.size)}) exceeds general limit (${this.formatFileSize(this.MAX_FILE_SIZE)})`);
@@ -4566,7 +4578,7 @@ var EnhancedSecureFileTransfer = class {
if (!metadata?.fileId || typeof metadata.fileId !== "string") errors.push("Invalid file id"); if (!metadata?.fileId || typeof metadata.fileId !== "string") errors.push("Invalid file id");
if (!Number.isSafeInteger(metadata?.fileSize) || metadata.fileSize <= 0) errors.push("Invalid file size"); if (!Number.isSafeInteger(metadata?.fileSize) || metadata.fileSize <= 0) errors.push("Invalid file size");
if (!Number.isSafeInteger(metadata?.totalChunks) || metadata.totalChunks <= 0) errors.push("Invalid chunk count"); if (!Number.isSafeInteger(metadata?.totalChunks) || metadata.totalChunks <= 0) errors.push("Invalid chunk count");
if (!Number.isSafeInteger(metadata?.chunkSize) || metadata.chunkSize <= 0 || metadata.chunkSize > this.CHUNK_SIZE) errors.push("Invalid chunk size"); if (!Number.isSafeInteger(metadata?.chunkSize) || metadata.chunkSize <= 0 || metadata.chunkSize > this.MAX_RECEIVE_CHUNK_SIZE) errors.push("Invalid chunk size");
if (!Array.isArray(metadata?.salt) || metadata.salt.length !== 32) errors.push("Invalid salt"); if (!Array.isArray(metadata?.salt) || metadata.salt.length !== 32) errors.push("Invalid salt");
const rawName = typeof metadata?.fileName === "string" ? metadata.fileName : ""; const rawName = typeof metadata?.fileName === "string" ? metadata.fileName : "";
const displayName = this.normalizeDisplayFileName(rawName); const displayName = this.normalizeDisplayFileName(rawName);
+2 -2
View File
File diff suppressed because one or more lines are too long
+5 -5
View File
@@ -113,7 +113,7 @@
<!-- GitHub Pages SEO --> <!-- GitHub Pages SEO -->
<meta name="description" content="SecureBit.chat v4.8.10 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography"> <meta name="description" content="SecureBit.chat v4.8.11 — P2P messenger with ECDH + DTLS + SAS security and 18-layer military-grade cryptography">
<meta name="keywords" content="P2P messenger, ECDH, DTLS, SAS, encryption, WebRTC, privacy, ASN.1 validation, military-grade security, 18-layer defense, MITM protection, PFS"> <meta name="keywords" content="P2P messenger, ECDH, DTLS, SAS, encryption, WebRTC, privacy, ASN.1 validation, military-grade security, 18-layer defense, MITM protection, PFS">
<meta name="author" content="Volodymyr"> <meta name="author" content="Volodymyr">
<link rel="canonical" href="https://github.com/SecureBitChat/securebit-chat/"> <link rel="canonical" href="https://github.com/SecureBitChat/securebit-chat/">
@@ -148,13 +148,13 @@
<!-- Update Manager - система принудительного обновления --> <!-- Update Manager - система принудительного обновления -->
<script src="src/utils/updateManager.js"></script> <script src="src/utils/updateManager.js"></script>
<script type="module" src="src/components/UpdateChecker.jsx"></script> <script type="module" src="src/components/UpdateChecker.jsx"></script>
<script type="module" src="dist/qr-local.js?v=1781588965220"></script> <script type="module" src="dist/qr-local.js?v=1781648539643"></script>
<script type="module" src="src/components/QRScanner.js?v=1781588965220"></script> <script type="module" src="src/components/QRScanner.js?v=1781648539643"></script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<script type="module" src="dist/app-boot.js?v=1781588965220"></script> <script type="module" src="dist/app-boot.js?v=1781648539643"></script>
<script type="module" src="dist/app.js?v=1781588965220"></script> <script type="module" src="dist/app.js?v=1781648539643"></script>
<script src="src/scripts/pwa-register.js"></script> <script src="src/scripts/pwa-register.js"></script>
<script src="./src/pwa/install-prompt.js" type="module"></script> <script src="./src/pwa/install-prompt.js" type="module"></script>
+1 -1
View File
@@ -1,5 +1,5 @@
{ {
"name": "SecureBit.chat v4.8.10 - ECDH + DTLS + SAS", "name": "SecureBit.chat v4.8.11 - ECDH + DTLS + SAS",
"short_name": "SecureBit", "short_name": "SecureBit",
"description": "P2P messenger with ECDH + DTLS + SAS security, military-grade cryptography and Lightning Network payments", "description": "P2P messenger with ECDH + DTLS + SAS security, military-grade cryptography and Lightning Network payments",
"start_url": "./", "start_url": "./",
+7 -7
View File
@@ -1,10 +1,10 @@
{ {
"version": "1781588965220", "version": "1781648539643",
"buildVersion": "1781588965220", "buildVersion": "1781648539643",
"appVersion": "4.8.10", "appVersion": "4.8.11",
"buildTime": "2026-06-16T05:49:25.266Z", "buildTime": "2026-06-16T22:22:19.692Z",
"buildId": "1781588965220-6dac4ce", "buildId": "1781648539643-9244250",
"gitHash": "6dac4ce", "gitHash": "9244250",
"generated": true, "generated": true,
"generatedAt": "2026-06-16T05:49:25.268Z" "generatedAt": "2026-06-16T22:22:19.693Z"
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "securebit-chat", "name": "securebit-chat",
"version": "4.8.10", "version": "4.8.11",
"description": "Secure P2P Communication Application with End-to-End Encryption", "description": "Secure P2P Communication Application with End-to-End Encryption",
"main": "index.html", "main": "index.html",
"scripts": { "scripts": {
+55 -20
View File
@@ -278,7 +278,20 @@ class EnhancedSecureFileTransfer {
this.verificationKey = null; this.verificationKey = null;
// Transfer settings // Transfer settings
this.CHUNK_SIZE = 64 * 1024; // 64 KB // NOTE: chunks are AES-GCM encrypted (+16-byte tag) and Base64-encoded
// before being wrapped in a JSON `file_chunk` message. Base64 inflates the
// payload by ~4/3, so the actual bytes handed to RTCDataChannel.send() are
// much larger than CHUNK_SIZE. The SCTP interop floor for a single WebRTC
// message is 64 KB (65536 bytes) — Safari and any peer whose SDP omits
// `a=max-message-size` enforce exactly this limit and will throw on larger
// sends. A 16 KB chunk yields a ~22 KB on-wire message, safely under that
// floor on every browser. (64 KB chunks produced ~87 KB messages, which
// silently failed to send and broke transfers cross-browser.)
this.CHUNK_SIZE = 16 * 1024; // 16 KB raw -> ~22 KB on the wire (SCTP-safe)
// Inbound chunks may legitimately be larger (e.g. an older peer that still
// sends 64 KB chunks), so validate received metadata against this ceiling
// rather than our own outbound CHUNK_SIZE.
this.MAX_RECEIVE_CHUNK_SIZE = 64 * 1024;
this.MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB limit this.MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB limit
this.MAX_CONCURRENT_TRANSFERS = 3; this.MAX_CONCURRENT_TRANSFERS = 3;
this.CHUNK_TIMEOUT = 30000; // 30 seconds per chunk this.CHUNK_TIMEOUT = 30000; // 30 seconds per chunk
@@ -287,7 +300,7 @@ class EnhancedSecureFileTransfer {
this.FILE_TYPE_RESTRICTIONS = { this.FILE_TYPE_RESTRICTIONS = {
pdf: { pdf: {
extensions: ['.pdf'], extensions: ['.pdf'],
mimeTypes: ['application/pdf'], mimeTypes: ['application/pdf', 'application/x-pdf', 'application/acrobat'],
maxSize: 50 * 1024 * 1024, maxSize: 50 * 1024 * 1024,
category: 'PDF', category: 'PDF',
description: 'PDF' description: 'PDF'
@@ -295,30 +308,39 @@ class EnhancedSecureFileTransfer {
text: { text: {
extensions: ['.txt'], extensions: ['.txt'],
mimeTypes: ['text/plain'], mimeTypes: ['text/plain', 'application/txt'],
maxSize: 10 * 1024 * 1024, maxSize: 10 * 1024 * 1024,
category: 'Plain text', category: 'Plain text',
description: 'TXT' description: 'TXT'
}, },
images: { images: {
extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.ico'], extensions: ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.ico'],
mimeTypes: [ mimeTypes: [
'image/jpeg', 'image/jpeg',
'image/jpg',
'image/pjpeg',
'image/png', 'image/png',
'image/gif', 'image/gif',
'image/webp', 'image/webp',
'image/bmp', 'image/bmp',
'image/x-icon' 'image/x-windows-bmp',
'image/x-icon',
'image/vnd.microsoft.icon'
], ],
maxSize: 25 * 1024 * 1024, // 25 MB maxSize: 25 * 1024 * 1024, // 25 MB
category: 'Images', category: 'Images',
description: 'JPG, JPEG, PNG, GIF, WEBP, BMP, ICO' description: 'JPG, JPEG, PNG, GIF, WEBP, BMP, ICO'
}, },
archives: { archives: {
extensions: ['.zip'], extensions: ['.zip'],
mimeTypes: ['application/zip'], mimeTypes: [
'application/zip',
'application/x-zip',
'application/x-zip-compressed',
'multipart/x-zip'
],
maxSize: 100 * 1024 * 1024, // 100 MB maxSize: 100 * 1024 * 1024, // 100 MB
category: 'Archives', category: 'Archives',
description: 'ZIP' description: 'ZIP'
@@ -328,6 +350,15 @@ class EnhancedSecureFileTransfer {
'.exe', '.bat', '.cmd', '.sh', '.js', '.msi', '.dmg', '.app', '.exe', '.bat', '.cmd', '.sh', '.js', '.msi', '.dmg', '.app',
'.jar', '.scr', '.ps1', '.vbs', '.html', '.svg' '.jar', '.scr', '.ps1', '.vbs', '.html', '.svg'
]); ]);
// Generic MIME types browsers emit when they cannot determine a real one.
// Treated as acceptable for any allowed extension.
this._genericMimeTypes = new Set(['application/octet-stream', 'application/binary']);
// Union of every recognised allowed MIME type (incl. cross-OS aliases),
// used to keep MIME advisory rather than a strict per-type gate.
this._allowedMimeTypes = new Set();
for (const typeConfig of Object.values(this.FILE_TYPE_RESTRICTIONS)) {
for (const mime of typeConfig.mimeTypes) this._allowedMimeTypes.add(mime);
}
// Active transfers tracking // Active transfers tracking
this.activeTransfers = new Map(); // fileId -> transfer state this.activeTransfers = new Map(); // fileId -> transfer state
@@ -367,16 +398,27 @@ class EnhancedSecureFileTransfer {
const fileExtension = extensionIndex >= 0 ? fileName.substring(extensionIndex) : ''; const fileExtension = extensionIndex >= 0 ? fileName.substring(extensionIndex) : '';
const mimeType = String(file?.type || '').toLowerCase(); const mimeType = String(file?.type || '').toLowerCase();
// The extension allow-list (plus BLOCKED_EXTENSIONS) is the security
// boundary. MIME is only an advisory signal: it is client-supplied,
// varies across browsers/OSes, and is frequently empty. We accept an
// allowed extension when the MIME is absent, generic, or belongs to any
// recognised allowed type, but still reject a blatantly foreign MIME
// (e.g. an executable MIME on a ".png") as a spoofing signal.
for (const [typeKey, typeConfig] of Object.entries(this.FILE_TYPE_RESTRICTIONS)) { for (const [typeKey, typeConfig] of Object.entries(this.FILE_TYPE_RESTRICTIONS)) {
const extensionAllowed = typeConfig.extensions.includes(fileExtension); const extensionAllowed = typeConfig.extensions.includes(fileExtension);
const mimeAllowed = typeConfig.mimeTypes.includes(mimeType); if (!extensionAllowed) continue;
if (extensionAllowed && mimeAllowed) { const mimeAcceptable = !mimeType
|| this._genericMimeTypes.has(mimeType)
|| this._allowedMimeTypes.has(mimeType);
if (mimeAcceptable) {
return { return {
type: typeKey, type: typeKey,
category: typeConfig.category, category: typeConfig.category,
description: typeConfig.description, description: typeConfig.description,
maxSize: typeConfig.maxSize, maxSize: typeConfig.maxSize,
allowed: true allowed: true,
extension: fileExtension,
mimeType
}; };
} }
} }
@@ -399,24 +441,17 @@ class EnhancedSecureFileTransfer {
const lowerName = fileName.toLowerCase(); const lowerName = fileName.toLowerCase();
const extensionIndex = lowerName.lastIndexOf('.'); const extensionIndex = lowerName.lastIndexOf('.');
const fileExtension = extensionIndex >= 0 ? lowerName.substring(extensionIndex) : ''; const fileExtension = extensionIndex >= 0 ? lowerName.substring(extensionIndex) : '';
const mimeType = String(file?.type || '').toLowerCase();
if (this.BLOCKED_EXTENSIONS.has(fileExtension)) { if (this.BLOCKED_EXTENSIONS.has(fileExtension)) {
errors.push(`File rejected: ${fileExtension} files are not allowed for security reasons.`); errors.push(`File rejected: ${fileExtension} files are not allowed for security reasons.`);
} }
if (!mimeType) {
errors.push('File rejected: missing MIME type is unsafe.');
}
if (file.size > fileType.maxSize) { if (file.size > fileType.maxSize) {
errors.push(`File size (${this.formatFileSize(file.size)}) exceeds maximum allowed for ${fileType.category} (${this.formatFileSize(fileType.maxSize)})`); errors.push(`File size (${this.formatFileSize(file.size)}) exceeds maximum allowed for ${fileType.category} (${this.formatFileSize(fileType.maxSize)})`);
} }
if (!fileType.allowed) { if (!fileType.allowed && !this.BLOCKED_EXTENSIONS.has(fileExtension)) {
if (mimeType && !this.BLOCKED_EXTENSIONS.has(fileExtension)) { errors.push(`File rejected: unsupported file type. Supported types: ${fileType.description}`);
errors.push(`File rejected: extension and MIME type must match an allowed type. Supported types: ${fileType.description}`);
}
} }
if (file.size > this.MAX_FILE_SIZE) { if (file.size > this.MAX_FILE_SIZE) {
@@ -447,7 +482,7 @@ class EnhancedSecureFileTransfer {
if (!metadata?.fileId || typeof metadata.fileId !== 'string') errors.push('Invalid file id'); if (!metadata?.fileId || typeof metadata.fileId !== 'string') errors.push('Invalid file id');
if (!Number.isSafeInteger(metadata?.fileSize) || metadata.fileSize <= 0) errors.push('Invalid file size'); if (!Number.isSafeInteger(metadata?.fileSize) || metadata.fileSize <= 0) errors.push('Invalid file size');
if (!Number.isSafeInteger(metadata?.totalChunks) || metadata.totalChunks <= 0) errors.push('Invalid chunk count'); if (!Number.isSafeInteger(metadata?.totalChunks) || metadata.totalChunks <= 0) errors.push('Invalid chunk count');
if (!Number.isSafeInteger(metadata?.chunkSize) || metadata.chunkSize <= 0 || metadata.chunkSize > this.CHUNK_SIZE) errors.push('Invalid chunk size'); if (!Number.isSafeInteger(metadata?.chunkSize) || metadata.chunkSize <= 0 || metadata.chunkSize > this.MAX_RECEIVE_CHUNK_SIZE) errors.push('Invalid chunk size');
if (!Array.isArray(metadata?.salt) || metadata.salt.length !== 32) errors.push('Invalid salt'); if (!Array.isArray(metadata?.salt) || metadata.salt.length !== 32) errors.push('Invalid salt');
const rawName = typeof metadata?.fileName === 'string' ? metadata.fileName : ''; const rawName = typeof metadata?.fileName === 'string' ? metadata.fileName : '';
+14 -5
View File
@@ -17,23 +17,32 @@ function file(name, type, size = 1024) {
const system = createSystem(); const system = createSystem();
// Allowed files // Allowed files (canonical MIME types)
assert.equal(system.validateFile(file('photo.png', 'image/png')).isValid, true); assert.equal(system.validateFile(file('photo.png', 'image/png')).isValid, true);
assert.equal(system.validateFile(file('report.pdf', 'application/pdf')).isValid, true); assert.equal(system.validateFile(file('report.pdf', 'application/pdf')).isValid, true);
assert.equal(system.validateFile(file('notes.txt', 'text/plain')).isValid, true); assert.equal(system.validateFile(file('notes.txt', 'text/plain')).isValid, true);
assert.equal(system.validateFile(file('bundle.zip', 'application/zip')).isValid, true); assert.equal(system.validateFile(file('bundle.zip', 'application/zip')).isValid, true);
// Explicitly blocked extensions // MIME is advisory: a safe extension is accepted when the MIME is missing,
// generic, or a cross-OS/browser variant of an allowed type.
assert.equal(system.validateFile(file('photo.png', '')).isValid, true);
assert.equal(system.validateFile(file('photo.png', 'application/octet-stream')).isValid, true);
assert.equal(system.validateFile(file('photo.jpg', 'image/jpg')).isValid, true);
assert.equal(system.validateFile(file('bundle.zip', 'application/x-zip-compressed')).isValid, true);
// Explicitly blocked extensions are always rejected, whatever the MIME claims.
for (const name of ['run.exe', 'boot.bat', 'shell.sh', 'payload.js', 'page.html', 'vector.svg']) { for (const name of ['run.exe', 'boot.bat', 'shell.sh', 'payload.js', 'page.html', 'vector.svg']) {
assert.equal(system.validateFile(file(name, 'application/octet-stream')).isValid, false, name); assert.equal(system.validateFile(file(name, 'application/octet-stream')).isValid, false, name);
} }
// MIME spoofing: safe extension with unsafe MIME and unsafe extension with safe MIME are blocked. // Spoofing is still blocked: a blatantly foreign MIME on a safe extension is
// rejected, and an unsafe extension with a safe MIME is rejected.
assert.equal(system.validateFile(file('photo.png', 'application/x-msdownload')).isValid, false); assert.equal(system.validateFile(file('photo.png', 'application/x-msdownload')).isValid, false);
assert.equal(system.validateFile(file('payload.exe', 'image/png')).isValid, false); assert.equal(system.validateFile(file('payload.exe', 'image/png')).isValid, false);
// Missing MIME is unsafe. // Unsupported (but not dangerous) extensions are rejected even with empty MIME.
assert.equal(system.validateFile(file('photo.png', '')).isValid, false); assert.equal(system.validateFile(file('movie.mp4', 'video/mp4')).isValid, false);
assert.equal(system.validateFile(file('archive.rar', '')).isValid, false);
// Uppercase extension bypass is blocked. // Uppercase extension bypass is blocked.
assert.equal(system.validateFile(file('PAYLOAD.EXE', 'application/octet-stream')).isValid, false); assert.equal(system.validateFile(file('PAYLOAD.EXE', 'application/octet-stream')).isValid, false);