package vip.mate.channel; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.util.Locale; import java.util.Objects; import java.util.Set; /** * RFC-03 Lane K3 — centralized validation for media files passing through * channel adapters (incoming attachments + outgoing replies). * *

Each {@code *ChannelAdapter} (DingTalk / Feishu / Discord / Telegram / * QQ / WeCom / Weixin / Slack) historically inlined its own ad-hoc set of * checks — extension allowlist here, size cap there, no path-traversal * guard at all. The result is 8 places to keep in sync when a new file * type is supported (or when a new exploit needs a defensive patch). * *

This guard centralizes four checks that every adapter needs: *

    *
  1. Path containment — the file resolves under the configured * workspace root (after symlink resolution). Blocks {@code ../} * traversal even when the attacker controls just a filename string.
  2. *
  3. Existence + regular file — not a directory, not a symlink * to a special device.
  4. *
  5. Extension allowlist — case-insensitive, dot-prefix tolerant.
  6. *
  7. Size cap — checked once via {@link Files#size(Path)} so a * gigabyte-plus file gets rejected before any adapter sends it.
  8. *
* *

Adapter migration is staged: this utility lands and ships with tests, * then individual adapters convert their inline checks one PR at a time * (Phase 1 starts with DingTalk per the RFC). Keeping this class * single-purpose so it doesn't depend on any adapter-specific service * means the migration can proceed without ripple-edits. */ public final class MediaPathGuard { private MediaPathGuard() {} /** * Lookup-friendly representation of a violation. The {@link #reason} * codes are stable so audit logs / metrics can group failures without * parsing the message text. */ public enum Reason { PATH_OUTSIDE_WORKSPACE, FILE_MISSING, NOT_A_REGULAR_FILE, EXTENSION_NOT_ALLOWED, FILE_TOO_LARGE, IO_ERROR } /** Validation policy — immutable, share across adapter calls. */ public record Policy( Path workspaceRoot, Set allowedExtensions, long maxBytes ) { public Policy { Objects.requireNonNull(workspaceRoot, "workspaceRoot must not be null"); Objects.requireNonNull(allowedExtensions, "allowedExtensions must not be null"); if (maxBytes <= 0) { throw new IllegalArgumentException("maxBytes must be positive: " + maxBytes); } // Normalize allowed extensions: lowercase, strip leading dots. allowedExtensions = Set.copyOf(allowedExtensions.stream() .filter(Objects::nonNull) .map(e -> e.toLowerCase(Locale.ROOT)) .map(e -> e.startsWith(".") ? e.substring(1) : e) .toList()); } } /** Thrown when validation fails — caller decides whether to log + drop * silently or surface to the user. */ public static final class MediaValidationException extends RuntimeException { private final Reason reason; public MediaValidationException(Reason reason, String message) { super(message); this.reason = reason; } public MediaValidationException(Reason reason, String message, Throwable cause) { super(message, cause); this.reason = reason; } public Reason reason() { return reason; } } /** * Validate {@code file} against {@code policy}. Returns the canonical * (real, symlink-resolved) path on success — callers that need to act * on the file (read bytes, upload to platform) should use the returned * value to avoid TOCTOU between validation and use. * * @throws MediaValidationException with a {@link Reason} that maps 1:1 * to the four documented checks; the message is human-readable * and includes the offending path / extension / size. */ public static Path validate(Path file, Policy policy) { Objects.requireNonNull(file, "file must not be null"); Objects.requireNonNull(policy, "policy must not be null"); Path canonicalRoot; Path canonicalFile; try { canonicalRoot = policy.workspaceRoot().toRealPath(); } catch (IOException e) { throw new MediaValidationException(Reason.IO_ERROR, "workspace root cannot be resolved: " + policy.workspaceRoot(), e); } try { canonicalFile = file.toRealPath(); } catch (IOException e) { // toRealPath fails when file does not exist OR when traversal // hits a non-readable segment. Distinguish the common case // (missing file) for clearer audit output. if (!Files.exists(file)) { throw new MediaValidationException(Reason.FILE_MISSING, "file does not exist: " + file, e); } throw new MediaValidationException(Reason.IO_ERROR, "file path cannot be resolved: " + file, e); } // Containment: canonicalFile must be inside canonicalRoot. startsWith on // Path does element-wise comparison so "/ws-foo" doesn't mistakenly look // like a prefix of "/ws-foobar". if (!canonicalFile.startsWith(canonicalRoot)) { throw new MediaValidationException(Reason.PATH_OUTSIDE_WORKSPACE, "file is outside the workspace: " + canonicalFile + " not under " + canonicalRoot); } if (!Files.isRegularFile(canonicalFile)) { throw new MediaValidationException(Reason.NOT_A_REGULAR_FILE, "not a regular file (directory / device / fifo): " + canonicalFile); } String ext = extensionOf(canonicalFile); if (!policy.allowedExtensions().contains(ext)) { throw new MediaValidationException(Reason.EXTENSION_NOT_ALLOWED, "extension '" + ext + "' not in allowlist; got " + canonicalFile); } long size; try { size = Files.size(canonicalFile); } catch (IOException e) { throw new MediaValidationException(Reason.IO_ERROR, "cannot read file size: " + canonicalFile, e); } if (size > policy.maxBytes()) { throw new MediaValidationException(Reason.FILE_TOO_LARGE, "file size " + size + " exceeds policy cap " + policy.maxBytes() + ": " + canonicalFile); } return canonicalFile; } /** Lowercase extension without leading dot; empty string when there is none. */ static String extensionOf(Path file) { String name = file.getFileName().toString(); int dot = name.lastIndexOf('.'); if (dot < 0 || dot == name.length() - 1) return ""; return name.substring(dot + 1).toLowerCase(Locale.ROOT); } }