From 4522dfaebc784fa1c386d968004518a237213cb0 Mon Sep 17 00:00:00 2001 From: matevip Date: Sat, 2 May 2026 15:41:27 +0800 Subject: [PATCH] feat(channel): MediaPathGuard utility for unified media validation --- .../java/vip/mate/channel/MediaPathGuard.java | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 mateclaw-server/src/main/java/vip/mate/channel/MediaPathGuard.java diff --git a/mateclaw-server/src/main/java/vip/mate/channel/MediaPathGuard.java b/mateclaw-server/src/main/java/vip/mate/channel/MediaPathGuard.java new file mode 100644 index 00000000..61598354 --- /dev/null +++ b/mateclaw-server/src/main/java/vip/mate/channel/MediaPathGuard.java @@ -0,0 +1,175 @@ +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). + * Same gap as QwenPaw #1220. + * + *

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); + } +}