|
13 | 13 | import java.nio.charset.Charset; |
14 | 14 | import java.nio.file.Files; |
15 | 15 | import java.nio.file.Path; |
| 16 | +import java.nio.file.StandardOpenOption; |
16 | 17 | import java.time.ZonedDateTime; |
17 | 18 | import java.util.ArrayList; |
18 | 19 | import java.util.Arrays; |
@@ -280,6 +281,82 @@ public static void sanitizeByCheckingIfPathStartsWithSubPathElseThrow(@NonNull U |
280 | 281 | } |
281 | 282 | } |
282 | 283 |
|
| 284 | + /** |
| 285 | + * Resolves a single filename against a base directory and guarantees that the result stays inside that directory. |
| 286 | + * |
| 287 | + * <p> |
| 288 | + * {@link #sanitizeFilename(String)} already replaces every path separator, so a sanitised name cannot traverse on |
| 289 | + * its own. This method adds the containment check at the point of use, which is what makes the guarantee local and |
| 290 | + * checkable: the resolved path is normalised and compared against the normalised base directory, so a caller that |
| 291 | + * forgets to sanitise — or a future change that loosens the sanitiser — fails loudly instead of quietly reading or |
| 292 | + * writing an arbitrary file. |
| 293 | + * |
| 294 | + * <p> |
| 295 | + * The containment this gives is <em>lexical</em>: it compares path elements and does not resolve symlinks, so a |
| 296 | + * link already present inside {@code baseDirectory} would still point elsewhere. Callers that create the file are |
| 297 | + * responsible for opening it with {@link java.nio.file.StandardOpenOption#CREATE_NEW}, which refuses to follow an |
| 298 | + * existing link, rather than relying on this check alone. |
| 299 | + * |
| 300 | + * @param baseDirectory the directory the resolved path has to stay within |
| 301 | + * @param filename the single filename to resolve against {@code baseDirectory} |
| 302 | + * @return the resolved, normalised path, guaranteed to lie inside {@code baseDirectory} |
| 303 | + * @throws IllegalArgumentException if the filename is blank or escapes {@code baseDirectory} |
| 304 | + */ |
| 305 | + @NonNull |
| 306 | + public static Path resolveWithinDirectoryElseThrow(@NonNull Path baseDirectory, @NonNull String filename) { |
| 307 | + if (filename.isBlank()) { |
| 308 | + throw new IllegalArgumentException("Invalid filename: must not be blank."); |
| 309 | + } |
| 310 | + Path normalisedBaseDirectory = baseDirectory.normalize(); |
| 311 | + Path resolvedPath = normalisedBaseDirectory.resolve(filename).normalize(); |
| 312 | + // startsWith() compares path elements, not characters, so a sibling directory sharing a name prefix cannot pass. |
| 313 | + if (!resolvedPath.startsWith(normalisedBaseDirectory) || resolvedPath.equals(normalisedBaseDirectory)) { |
| 314 | + throw new IllegalArgumentException("Invalid filename '%s': the resolved path escapes the expected directory.".formatted(filename)); |
| 315 | + } |
| 316 | + return resolvedPath; |
| 317 | + } |
| 318 | + |
| 319 | + /** |
| 320 | + * Writes a stream to a path that must not exist yet, creating any missing parent directories. |
| 321 | + * |
| 322 | + * <p> |
| 323 | + * Opens with {@link StandardOpenOption#CREATE_NEW}, which maps to {@code O_CREAT | O_EXCL}. The kernel refuses |
| 324 | + * that combination when the path already exists — including when it exists only as a symlink, and including a |
| 325 | + * dangling one — so the write cannot be redirected through a link planted at the destination. This is the |
| 326 | + * companion to {@link #resolveWithinDirectoryElseThrow(Path, String)}, whose containment check is lexical and |
| 327 | + * therefore blind to symlinks on its own. |
| 328 | + * |
| 329 | + * @param inputStream the stream to write; closed by the caller |
| 330 | + * @param target the file to create |
| 331 | + * @throws java.nio.file.FileAlreadyExistsException if {@code target} already exists, symlink included |
| 332 | + * @throws IOException if creating the directories or writing fails |
| 333 | + */ |
| 334 | + public static void writeNewFileElseThrow(@NonNull InputStream inputStream, @NonNull Path target) throws IOException { |
| 335 | + Path parent = target.getParent(); |
| 336 | + if (parent != null) { |
| 337 | + Files.createDirectories(parent); |
| 338 | + } |
| 339 | + // Opened outside the try so that a failure of the open itself is NOT cleaned up: it throws |
| 340 | + // FileAlreadyExistsException precisely when the path is somebody else's file or symlink, and that is the case |
| 341 | + // this method exists to protect. Only once the open succeeds is the file ours to delete. |
| 342 | + OutputStream outputStream = Files.newOutputStream(target, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE); |
| 343 | + try (outputStream) { |
| 344 | + inputStream.transferTo(outputStream); |
| 345 | + } |
| 346 | + catch (IOException | RuntimeException e) { |
| 347 | + // CREATE_NEW creates the file before any byte is copied, so a failure part-way through would otherwise leave |
| 348 | + // a truncated file behind. The caller only registers the path for deletion after this method returns, so |
| 349 | + // nothing else would ever remove it. |
| 350 | + try { |
| 351 | + Files.deleteIfExists(target); |
| 352 | + } |
| 353 | + catch (IOException cleanupFailure) { |
| 354 | + e.addSuppressed(cleanupFailure); |
| 355 | + } |
| 356 | + throw e; |
| 357 | + } |
| 358 | + } |
| 359 | + |
283 | 360 | /** |
284 | 361 | * Sanitizes a file path by checking for invalid characters or path traversal. |
285 | 362 | * |
|
0 commit comments