FileUtil.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.entrystore.rest.springboot.util;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.lang3.StringUtils;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.text.Normalizer;
import java.util.Set;
@Slf4j
@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE)
public class FileUtil {
private static final int MAX_DECODE_ITERATIONS = 3;
private final static Set<String> DANGEROUS_FILE_EXTENSIONS = Set.of(
"apk",
"app",
"asp",
"aspx",
"bat",
"bin",
"cab",
"cmd",
"com",
"command",
"cpl",
"csh",
"ex",
"exe",
"gadget",
"inf",
"ins",
"inx",
"ipa",
"isu",
"js",
"jse",
"jsp",
"jsx",
"ksh",
"lnk",
"msc",
"msi",
"msp",
"mst",
"osx",
"out",
"paf",
"pif",
"php",
"pl",
"plx",
"prg",
"ps1",
"rb",
"reg",
"rgs",
"run",
"scr",
"sct",
"shb",
"shs",
"u3p",
"vb",
"vbe",
"vbs",
"vbscript",
"workflow",
"ws",
"wsf",
"wsh");
/**
* Sanitizes a filename by appending a {@code _dangerous} suffix when it resolves to a
* dangerous extension.
* <p>
* The extension is determined from a normalized form of the filename:
* <ol>
* <li>URL-decoded iteratively until stable, defeating multi-encoded shapes such as
* {@code %2520}; the form-decoder {@code +}→space behavior is suppressed by
* escaping {@code +} before each decode pass.</li>
* <li>{@link String#strip()} applied (removing Java whitespace).</li>
* <li>Trailing characters iteratively removed if they are {@code .}, {@code ;},
* {@code /}, {@code \}, ASCII control chars (< 0x20), DEL (U+007F), C1 controls
* (U+0080–U+009F), Unicode space chars ({@link Character#isSpaceChar} —
* including NBSP, which {@code String.strip()} does NOT remove), or Unicode format
* chars ({@link Character#FORMAT} — zero-width separators, BOM and bidi
* overrides).</li>
* <li>{@link Normalizer.Form#NFKC} compatibility normalization, so fullwidth Latin
* spoofs (e.g. U+FF45 U+FF58 U+FF45) collapse to their ASCII equivalents before
* the extension check.</li>
* </ol>
* <p>
* Filenames are flagged with the {@code _dangerous} suffix when any of the following
* holds: the resolved extension is dangerous, decoding fails (truncated/non-hex
* {@code %}-escape or unstable after {@value #MAX_DECODE_ITERATIONS} passes), or the
* normalized form contains a control character (C0, DEL, or C1) anywhere —
* control characters in stored filenames break {@code Content-Disposition} response
* headers and have been used for header smuggling.
* <p>
* The returned filename is the normalized form with the suffix appended when
* applicable. On the failure paths (decode error or embedded control char), the
* trailing-stripped form of the raw input is returned with the suffix, so that
* bidi/format characters in the input do not persist into the stored filename.
*
* @param filename the original filename to sanitize, or {@code null}
* @return the normalized filename (with {@code _dangerous} suffix when applicable), or
* {@code null} if the input is {@code null}
*/
public static String sanitizeFilename(String filename) {
if (filename == null) {
return null;
}
String normalized;
try {
normalized = decodeUntilStable(filename);
} catch (IllegalArgumentException e) {
// Malformed %-escape (truncated, non-hex, or unstable after the iteration cap)
// — treat as suspicious. Normalize the input form too, so bidi/format chars
// in the raw input do not survive into the stored filename.
log.warn("Failed to URL-decode filename '{}', flagging as _dangerous: {}",
filename, e.getMessage());
return stripTrailingNoise(filename.strip()) + "_dangerous";
}
normalized = stripTrailingNoise(normalized.strip());
if (containsControlChar(normalized)) {
log.warn("Embedded control char in filename '{}', flagging as _dangerous", filename);
return stripTrailingNoise(filename.strip()) + "_dangerous";
}
normalized = Normalizer.normalize(normalized, Normalizer.Form.NFKC);
String fileExt = FilenameUtils.getExtension(normalized);
if (isDangerousExtension(fileExt)) {
log.info("Dangerous extension '{}' in filename '{}', appending _dangerous suffix",
fileExt, filename);
return normalized + "_dangerous";
}
return normalized;
}
/**
* URL-decodes the input up to {@value #MAX_DECODE_ITERATIONS} times until the result
* stabilizes, defeating multi-encoding bypass shapes such as {@code %2520} (which decodes
* to {@code %20} on the second pass and then to a literal space). Literal {@code +}
* characters are escaped to {@code %2B} before each pass so {@link URLDecoder}'s
* form-decoder semantics do not collapse them to spaces.
*
* @throws IllegalArgumentException if a {@code %}-escape is truncated, contains
* non-hex characters, or remains in the result after
* {@value #MAX_DECODE_ITERATIONS} decode passes (a
* deeper-than-supported encoding nesting)
*/
private static String decodeUntilStable(String input) {
String current = input;
for (int i = 0; i < MAX_DECODE_ITERATIONS; i++) {
String prev = current;
current = URLDecoder.decode(prev.replace("+", "%2B"), StandardCharsets.UTF_8);
if (current.equals(prev)) {
return current;
}
}
if (current.contains("%")) {
throw new IllegalArgumentException(
"Filename did not stabilize within " + MAX_DECODE_ITERATIONS + " decode passes");
}
return current;
}
/**
* Removes trailing characters that should be ignored when resolving the extension
* (see {@link #isTrailingNoise(char)}). Single-pass index scan plus one
* {@link String#substring(int, int)}, so cost is O(n) rather than O(n²).
*/
private static String stripTrailingNoise(String s) {
int end = s.length();
while (end > 0 && isTrailingNoise(s.charAt(end - 1))) {
end--;
}
return s.substring(0, end);
}
private static boolean isTrailingNoise(char c) {
return c == '.' || c == ';' || c == '/' || c == '\\'
|| c < ' ' || c == '\u007F'
|| (c >= '\u0080' && c <= '\u009F')
|| Character.isSpaceChar(c)
|| Character.getType(c) == Character.FORMAT;
}
/**
* Returns {@code true} if {@code s} contains any C0 control (U+0000–U+001F),
* DEL (U+007F), or C1 control (U+0080–U+009F) character. These characters are
* invalid in HTTP header field values per RFC 9110 §5.5 and have been used for
* header smuggling and filename-display spoofs.
*/
private static boolean containsControlChar(String s) {
for (int i = 0; i < s.length(); i++) {
char c = s.charAt(i);
if (c < 0x20 || c == 0x7F || (c >= 0x80 && c <= 0x9F)) {
return true;
}
}
return false;
}
/**
* Checks if a file extension is considered dangerous.
*
* @param extension the file extension to check (without dot)
* @return true if the extension is dangerous, false otherwise (including null and empty value)
*/
public static boolean isDangerousExtension(String extension) {
return StringUtils.isNotEmpty(extension) &&
DANGEROUS_FILE_EXTENSIONS.contains(extension.toLowerCase());
}
}