Skip to content

[BUG][CRITICAL] File sharing has no frontend validation β€” malicious file types accepted, no size limit, UI freezes silently on large uploads with zero user feedbackΒ #142

@uv05709

Description

@uv05709

πŸ“ Files: components/chat/FileUpload.jsx (or equivalent)

hooks/useFileUpload.js

styles/ β€” upload progress UI


🚨 Summary

NPMChat's file sharing feature accepts any file of any size with
zero client-side validation. There is:

  • ❌ No file type restriction (.exe, .sh, .bat, .php all accepted)
  • ❌ No file size limit (500MB+ uploads start silently)
  • ❌ No upload progress indicator (UI appears frozen on large files)
  • ❌ No cancel upload option
  • ❌ No error message on oversized files

For a developer-focused chat platform, this is a critical security
and UX failure β€” malicious scripts can be shared freely, and large
file uploads hang the UI with no feedback.


πŸ’₯ Reproducible Failures

Failure 1 β€” Malicious file accepted silently:

Open any chat room in NPMChat
Click file share / attachment icon
Select a file named "backdoor.exe" or "exploit.sh"
β†’ Expected: "File type not allowed" error shown, upload blocked
β†’ Actual: Upload begins immediately, file sent to all chat participants

Failure 2 β€” Large file freezes UI:

Select a 100MB+ video file
Click send / attach
β†’ Expected: Progress bar shown (0% β†’ 100%), cancel button available
β†’ Actual: UI appears frozen, no feedback, no way to cancel
Browser may become unresponsive

Failure 3 β€” No feedback on failure:

Select a 500MB file (exceeds server limit)
Upload starts β†’ server rejects with 413 Payload Too Large
β†’ Expected: "File too large. Max 10MB allowed" shown to user
β†’ Actual: Silent failure β€” upload just stops with no message


βœ… Proposed Fix β€” 4-Part Solution

Fix 1 β€” File validation config

// lib/fileValidation.ts

export const FILE_CONFIG = {
  maxSizeMB: 10,
  maxSizeBytes: 10 * 1024 * 1024, // 10MB

  // Allowed types for a developer chat platform
  allowedTypes: [
    // Images
    "image/jpeg", "image/png", "image/gif", "image/webp", "image/svg+xml",
    // Documents
    "application/pdf", "text/plain", "text/markdown",
    "application/json", "text/csv",
    // Code files
    "text/javascript", "text/typescript", "text/html", "text/css",
    "text/x-python", "text/x-java-source",
    // Archives (safe)
    "application/zip",
  ],

  // Blocked extensions (double-check beyond MIME type)
  blockedExtensions: [
    ".exe", ".sh", ".bat", ".cmd", ".ps1", ".php",
    ".py", ".rb", ".pl", ".vbs", ".js", // executables
    ".dll", ".so", ".dylib",             // binaries
  ],
} as const;

export type ValidationResult =
  | { valid: true }
  | { valid: false; error: string };

export function validateFile(file: File): ValidationResult {
  // Check size
  if (file.size > FILE_CONFIG.maxSizeBytes) {
    const sizeMB = (file.size / 1024 / 1024).toFixed(1);
    return {
      valid: false,
      error: `File too large (${sizeMB}MB). Maximum allowed size is ${FILE_CONFIG.maxSizeMB}MB.`,
    };
  }

  // Check MIME type
  if (!FILE_CONFIG.allowedTypes.includes(file.type as any)) {
    return {
      valid: false,
      error: `File type "${file.type || "unknown"}" is not allowed. Only images, documents, and code files are permitted.`,
    };
  }

  // Check extension (defense in depth)
  const ext = "." + file.name.split(".").pop()?.toLowerCase();
  if (FILE_CONFIG.blockedExtensions.includes(ext as any)) {
    return {
      valid: false,
      error: `Files with extension "${ext}" are not allowed for security reasons.`,
    };
  }

  return { valid: true };
}

Fix 2 β€” Upload progress hook

// hooks/useFileUpload.ts
import { useState, useRef, useCallback } from "react";
import { validateFile } from "@/lib/fileValidation";

interface UploadState {
  progress: number;         // 0-100
  status: "idle" | "validating" | "uploading" | "success" | "error" | "cancelled";
  error: string | null;
  fileName: string | null;
}

export function useFileUpload() {
  const [state, setState] = useState<UploadState>({
    progress: 0,
    status: "idle",
    error: null,
    fileName: null,
  });

  const xhrRef = useRef<XMLHttpRequest | null>(null);

  const upload = useCallback(async (file: File, roomId: string) => {
    // Step 1: Validate
    setState({ progress: 0, status: "validating", error: null, fileName: file.name });

    const validation = validateFile(file);
    if (!validation.valid) {
      setState((s) => ({ ...s, status: "error", error: validation.error }));
      return;
    }

    // Step 2: Upload with XHR for progress tracking
    setState((s) => ({ ...s, status: "uploading" }));

    const formData = new FormData();
    formData.append("file", file);
    formData.append("roomId", roomId);

    const xhr = new XMLHttpRequest();
    xhrRef.current = xhr;

    return new Promise<void>((resolve, reject) => {
      // βœ… Track progress
      xhr.upload.addEventListener("progress", (e) => {
        if (e.lengthComputable) {
          const pct = Math.round((e.loaded / e.total) * 100);
          setState((s) => ({ ...s, progress: pct }));
        }
      });

      xhr.addEventListener("load", () => {
        if (xhr.status >= 200 && xhr.status < 300) {
          setState((s) => ({ ...s, status: "success", progress: 100 }));
          resolve();
        } else if (xhr.status === 413) {
          setState((s) => ({
            ...s, status: "error",
            error: "File too large. Server rejected the upload.",
          }));
          reject();
        } else {
          setState((s) => ({
            ...s, status: "error",
            error: "Upload failed. Please try again.",
          }));
          reject();
        }
      });

      xhr.addEventListener("error", () => {
        setState((s) => ({ ...s, status: "error", error: "Network error during upload." }));
        reject();
      });

      xhr.open("POST", "/api/upload");
      xhr.send(formData);
    });
  }, []);

  // βœ… Cancel in-flight upload
  const cancel = useCallback(() => {
    if (xhrRef.current) {
      xhrRef.current.abort();
      xhrRef.current = null;
      setState({ progress: 0, status: "cancelled", error: null, fileName: null });
    }
  }, []);

  const reset = useCallback(() => {
    setState({ progress: 0, status: "idle", error: null, fileName: null });
  }, []);

  return { ...state, upload, cancel, reset };
}

Fix 3 β€” FileUpload UI component with progress + cancel

// components/chat/FileUpload.tsx
import { useRef } from "react";
import { useFileUpload } from "@/hooks/useFileUpload";
import { Paperclip, X, CheckCircle, AlertCircle } from "lucide-react";

interface Props {
  roomId: string;
}

export function FileUpload({ roomId }: Props) {
  const inputRef = useRef<HTMLInputElement>(null);
  const { progress, status, error, fileName, upload, cancel, reset } =
    useFileUpload();

  const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
    const file = e.target.files?.[0];
    if (!file) return;
    await upload(file, roomId);
    // Reset input so same file can be re-selected
    if (inputRef.current) inputRef.current.value = "";
  };

  return (
    <div className="file-upload-wrapper">
      {/* Hidden native input */}
      <input
        ref={inputRef}
        type="file"
        className="hidden"
        onChange={handleFileSelect}
        aria-label="Attach file"
      />

      {/* Upload trigger button */}
      {status === "idle" || status === "success" || status === "cancelled" ? (
        <button
          onClick={() => { reset(); inputRef.current?.click(); }}
          className="attach-btn"
          title="Attach file (max 10MB)"
        >
          <Paperclip className="w-5 h-5" />
        </button>
      ) : null}

      {/* Progress bar β€” shown during upload */}
      {status === "uploading" || status === "validating" ? (
        <div className="upload-progress-container">
          <span className="upload-filename truncate">{fileName}</span>

          <div className="progress-bar-track">
            <div
              className="progress-bar-fill"
              style={{ width: `${progress}%` }}
              role="progressbar"
              aria-valuenow={progress}
              aria-valuemin={0}
              aria-valuemax={100}
            />
          </div>

          <span className="upload-pct">{progress}%</span>

          {/* βœ… Cancel button */}
          <button
            onClick={cancel}
            className="cancel-btn"
            title="Cancel upload"
          >
            <X className="w-4 h-4" />
          </button>
        </div>
      ) : null}

      {/* Error message */}
      {status === "error" && error && (
        <div className="upload-error" role="alert">
          <AlertCircle className="w-4 h-4 text-red-500" />
          <span>{error}</span>
          <button onClick={reset} className="text-xs underline ml-2">
            Dismiss
          </button>
        </div>
      )}

      {/* Success */}
      {status === "success" && (
        <div className="upload-success">
          <CheckCircle className="w-4 h-4 text-green-500" />
          <span>File sent!</span>
        </div>
      )}
    </div>
  );
}

Fix 4 β€” Progress bar CSS

/* styles/fileUpload.css */

.upload-progress-container {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 10px;
  background: var(--bg-secondary, #f5f5f5);
  border-radius: 8px;
  min-width: 240px;
}

.upload-filename {
  font-size: 0.75rem;
  max-width: 120px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.progress-bar-track {
  flex: 1;
  height: 6px;
  background: #ddd;
  border-radius: 3px;
  overflow: hidden;
}

.progress-bar-fill {
  height: 100%;
  background: #3b82f6;
  border-radius: 3px;
  transition: width 0.2s ease;
}

.upload-pct {
  font-size: 0.7rem;
  color: #666;
  min-width: 30px;
}

.upload-error {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  background: #fef2f2;
  border: 1px solid #fca5a5;
  border-radius: 6px;
  font-size: 0.8rem;
  color: #dc2626;
}

.upload-success {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 0.8rem;
  color: #16a34a;
}

πŸ“Š Impact Table

Failure Before Fix After Fix
.exe / .sh uploaded βœ… Accepted silently ❌ Blocked: "type not allowed"
200MB video upload UI freezes silently Progress bar + cancel button shown
413 server rejection Silent failure "File too large" message shown
Upload progress Unknown (spinner or freeze) Live 0–100% progress bar
Cancel mid-upload Impossible βœ… Cancel button aborts XHR
Wrong file type No feedback Inline error with clear message

πŸ“ Files to Add / Modify

File Change
lib/fileValidation.ts New β€” type/size/extension validator
hooks/useFileUpload.ts New β€” XHR upload with progress + cancel
components/chat/FileUpload.tsx Replace existing with progress UI
styles/fileUpload.css New β€” progress bar + error/success styles

πŸ§ͺ How to Test

Try uploading "test.exe" β†’ blocked: "type not allowed" βœ…
Try uploading a 50MB file β†’ blocked: "File too large (50MB). Max 10MB" βœ…
Upload a valid 2MB PNG β†’ progress bar animates 0β†’100% βœ…
Start a large upload β†’ click cancel β†’ XHR aborted, "cancelled" shown βœ…
Lose network mid-upload β†’ "Network error during upload" shown βœ…


πŸ™‹ Raising this for GSSoC 2025 contribution.
All 4 parts are clearly scoped. useFileUpload hook + validateFile
utility are fully reusable across the app.
Happy to implement if assigned.

Estimated effort: 4–6 hours | Difficulty: Level 2 (Intermediate)
Labels: critical Β· bug Β· security Β· frontend Β· UI/UX Β· level 2 Β· gssoc25 Β· help wanted

Metadata

Metadata

Assignees

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions