본문 바로가기
개발 (ENG)

💡 Implementing a Whitelist-Based Secure File Upload Validator

by 새싹 아빠 2025. 10. 31.

When building file upload features for public or government systems, simple extension checks are not enough. Security audits often require whitelist-based extension validation, double-extension blocking, and magic-byte verification. In this post, I’ll share how I implemented a secure file upload validator in

Spring Boot + Java, inspired by real-world public sector project requirements.

1️⃣ Design Overview

The upload validation process consists of four main steps:

  1. Whitelist extension validation — only approved extensions allowed
  2. Double extension blocking — prevent filenames like test.jsp.jpg
  3. Filename regex validation — allow only Korean/English/alphanumeric with limited symbols
  4. Magic-byte inspection — ensure the file’s actual content matches its extension

Validation occurs right before saving the file. The storage path and network directory are dynamically loaded from external configuration properties.

2️⃣ Implementation Code (Core Part)

The following is a simplified, reusable example of the core logic.


package com.example.security;

import java.io.*;
import java.nio.charset.StandardCharsets;
import java.nio.file.*;
import java.text.Normalizer;
import java.text.SimpleDateFormat;
import java.util.*;

/**
 * UnifiedUploadValidator
 *
 * - Whitelist-based extension validation
 * - Prohibits '.' in base name to prevent double extensions
 * - Magic-byte verification to confirm file integrity
 * - Filename regex: Korean/English/Numbers + (_ - () space)
 */
public class UnifiedUploadValidator {

    private final Set<String> allowedExt;
    private final long maxFileBytes;
    private final String baseNameRegex = "^[\\p{L}0-9 _\\-()]{1,200}$";

    public UnifiedUploadValidator(Set<String> allowedExt, long maxFileBytes) {
        this.allowedExt = new HashSet<>();
        for (String e : allowedExt) this.allowedExt.add(e.toLowerCase(Locale.ROOT));
        this.maxFileBytes = maxFileBytes;
    }

    /** Validate and store file */
    public String validateAndStore(Path baseDir, String subDir, String fileName, byte[] bytes) throws IOException {
        ParsedName parsed = parseFullNameStrict(fileName);
        validateCommon(parsed.base, parsed.ext, bytes);

        // Validate file content by magic bytes
        byte[] header = Arrays.copyOf(bytes, Math.min(bytes.length, 512));
        if (!matchesMagicBytes(header, parsed.ext)) {
            throw new IllegalArgumentException("File content mismatch: " + parsed.ext);
        }

        // Safe file name with timestamp
        String timeSuffix = new SimpleDateFormat("yyyyMMddHHmmss").format(new Date());
        String safeFileName = parsed.base + "(" + timeSuffix + ")." + parsed.ext;

        Path target = baseDir.resolve(subDir).resolve(safeFileName).normalize();
        Files.createDirectories(target.getParent());

        try (OutputStream os = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)) {
            os.write(bytes);
        }

        return safeFileName;
    }

    /** Internal utility: parse and validate filename */
    private ParsedName parseFullNameStrict(String fullName) {
        String name = Normalizer.normalize(fullName.trim(), Normalizer.Form.NFC);
        int idx = name.lastIndexOf('.');
        if (idx <= 0 || idx == name.length() - 1) {
            throw new IllegalArgumentException("Invalid file name: " + name);
        }
        String base = name.substring(0, idx);
        String ext = name.substring(idx + 1).toLowerCase(Locale.ROOT);
        if (base.contains(".")) throw new IllegalArgumentException("Double extension blocked: " + base);
        if (!allowedExt.contains(ext)) throw new IllegalArgumentException("Disallowed extension: " + ext);
        if (!base.matches(baseNameRegex)) throw new IllegalArgumentException("Invalid base name: " + base);
        return new ParsedName(base, ext);
    }

    /** Internal utility: size and extension validation */
    private void validateCommon(String base, String ext, byte[] bytes) {
        if (bytes == null) throw new IllegalArgumentException("File bytes required");
        if (bytes.length > maxFileBytes) throw new IllegalArgumentException("File too large");
        if (!allowedExt.contains(ext)) throw new IllegalArgumentException("Disallowed extension: " + ext);
    }

    /** Internal utility: magic byte check */
    private boolean matchesMagicBytes(byte[] header, String ext) {
        final java.util.function.BiPredicate<byte[], byte[]> startsWith = (hay, needle) -> {
            if (hay.length < needle.length) return false;
            for (int i = 0; i < needle.length; i++) if (hay[i] != needle[i]) return false;
            return true;
        };
        switch (ext) {
            case "png": return startsWith.test(header, new byte[]{(byte)0x89, 0x50, 0x4E, 0x47});
            case "jpg": case "jpeg": return startsWith.test(header, new byte[]{(byte)0xFF, (byte)0xD8});
            case "gif": return startsWith.test(header, "GIF".getBytes(StandardCharsets.US_ASCII));
            case "pdf": return startsWith.test(header, "%PDF".getBytes(StandardCharsets.US_ASCII));
            case "zip": return startsWith.test(header, new byte[]{0x50, 0x4B, 0x03, 0x04});
            default: return true; // text or simple files
        }
    }

    private static class ParsedName {
        final String base; final String ext;
        ParsedName(String base, String ext) { this.base = base; this.ext = ext; }
    }
}

3️⃣ Usage Example


Set<String> allowed = Set.of("jpg", "jpeg", "png", "gif", "pdf", "zip", "txt");
UnifiedUploadValidator validator = new UnifiedUploadValidator(allowed, 10 * 1024 * 1024); // 10MB limit

// Example usage when a file upload request is received:
Path baseDir = Paths.get("//fileserver/share/uploads");
String savedName = validator.validateAndStore(baseDir, "temp/", "profile.jpg", fileBytes);
System.out.println("Saved file name: " + savedName);

4️⃣ Security Effects

  • Blocks double extensions like test.jsp.jpg
  • Rejects non-whitelisted extensions
  • Detects malicious scripts via magic-byte mismatch
  • Automatically creates upload directories and prevents traversal attacks
Even if a penetration test flags "missing file upload validation," this validator will usually meet or exceed most security audit requirements.

5️⃣ Summary

File upload vulnerabilities are among the most common findings in security assessments. Relying only on client-side or extension checks is risky. By combining extension whitelisting, filename regex, size limits, and magic-byte validation on the server side, you can safely handle uploads even in critical government or enterprise systems.

This implementation is based on open security principles and does not include any institution-specific code. For production environments, you can externalize the path configuration in .properties files to easily manage different environments (dev, staging, production).

© 2025 by Jae Myung. All examples are anonymized and for educational purposes.