From db91605868996ff4d4b2f0b43acc56ed47a0ef41 Mon Sep 17 00:00:00 2001 From: Ekaterina Bulatova Date: Thu, 4 Jun 2026 13:30:14 +0200 Subject: [PATCH 1/2] fix(webapp): validate packet storage paths against traversal Packet-relative paths were used to construct object-store keys and presigned URLs without validation. Crafted paths containing traversal segments could escape the intended `packets/{projectRef}/{envSlug}/` prefix. Add `assertSafePacketRelativePath`, which rejects empty paths, leading `/`, backslashes, and empty/`.`/`..` path segments. Validation is enforced for: - packet uploads - packet downloads - packet presign requests Valid paths such as `run_123/payload.json` are unaffected. Adds unit tests covering path validation and presign behavior. --- .../validate-packet-storage-paths.md | 18 ++ apps/webapp/app/routes/api.v1.packets.$.ts | 6 +- apps/webapp/app/routes/api.v2.packets.$.ts | 4 +- apps/webapp/app/v3/objectStore.server.ts | 178 ++++++++++++++--- .../webapp/app/v3/objectStoreClient.server.ts | 16 +- apps/webapp/test/objectStore.test.ts | 184 ++++++++++++++++++ 6 files changed, 372 insertions(+), 34 deletions(-) create mode 100644 .server-changes/validate-packet-storage-paths.md diff --git a/.server-changes/validate-packet-storage-paths.md b/.server-changes/validate-packet-storage-paths.md new file mode 100644 index 00000000000..0bd594e987e --- /dev/null +++ b/.server-changes/validate-packet-storage-paths.md @@ -0,0 +1,18 @@ +--- +area: webapp +type: fix +--- + +Validate packet-relative storage paths before building object-store keys or presigned URLs. Rejects: + +- an empty path +- absolute paths (leading `/`) +- backslashes (`\`) +- empty path segments (e.g. `foo//bar`, leading or trailing `/`) +- `.` path segments (e.g. `.`, `foo/./bar`) +- `..` path segments (path traversal, e.g. `../file`, `foo/../bar`) +- percent-encoded `.` / `..` segments (e.g. `%2e%2e`, `%2E%2E`, `%2e.`) + +After segment checks, paths are normalized with the same URL pathname resolution used by `Aws4FetchClient`, and the full object-store key must remain under `packets/{projectRef}/{envSlug}/` after that normalization. + +Applied in `uploadPacketToObjectStore`, `downloadPacketFromObjectStore`, and `generatePresignedRequest`. `Aws4FetchClient` uses shared `normalizeObjectStoreLogicalKeyPathname` for presign/PUT/GET URLs. diff --git a/apps/webapp/app/routes/api.v1.packets.$.ts b/apps/webapp/app/routes/api.v1.packets.$.ts index 031f2854bbb..ff2cc7607a7 100644 --- a/apps/webapp/app/routes/api.v1.packets.$.ts +++ b/apps/webapp/app/routes/api.v1.packets.$.ts @@ -3,7 +3,7 @@ import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { authenticateApiRequest } from "~/services/apiAuth.server"; import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server"; -import { generatePresignedUrl } from "~/v3/objectStore.server"; +import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server"; const ParamsSchema = z.object({ "*": z.string(), @@ -34,7 +34,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ); if (!signed.success) { - return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + return jsonPacketPresignFailure(signed); } // Caller can now use this URL to upload to that object. @@ -59,7 +59,7 @@ export const loader = createLoaderApiRoute( ); if (!signed.success) { - return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + return jsonPacketPresignFailure(signed); } // Caller can now use this URL to fetch that object. diff --git a/apps/webapp/app/routes/api.v2.packets.$.ts b/apps/webapp/app/routes/api.v2.packets.$.ts index 8810dc6005a..04ec14c3301 100644 --- a/apps/webapp/app/routes/api.v2.packets.$.ts +++ b/apps/webapp/app/routes/api.v2.packets.$.ts @@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime"; import { json } from "@remix-run/server-runtime"; import { z } from "zod"; import { authenticateApiRequest } from "~/services/apiAuth.server"; -import { generatePresignedUrl } from "~/v3/objectStore.server"; +import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server"; const ParamsSchema = z.object({ "*": z.string(), @@ -34,7 +34,7 @@ export async function action({ request, params }: ActionFunctionArgs) { ); if (!signed.success) { - return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 }); + return jsonPacketPresignFailure(signed); } if (signed.storagePath === undefined) { diff --git a/apps/webapp/app/v3/objectStore.server.ts b/apps/webapp/app/v3/objectStore.server.ts index bd85fb905fa..16aa9d71ee4 100644 --- a/apps/webapp/app/v3/objectStore.server.ts +++ b/apps/webapp/app/v3/objectStore.server.ts @@ -1,9 +1,15 @@ +import { json } from "@remix-run/server-runtime"; import { type IOPacket } from "@trigger.dev/core/v3"; import { env } from "~/env.server"; import { type AuthenticatedEnvironment } from "~/services/apiAuth.server"; import { logger } from "~/services/logger.server"; +import { ServiceValidationError } from "~/v3/services/common.server"; import { singleton } from "~/utils/singleton"; -import { ObjectStoreClient, type ObjectStoreClientConfig } from "./objectStoreClient.server"; +import { + normalizeObjectStoreLogicalKeyPathname, + ObjectStoreClient, + type ObjectStoreClientConfig, +} from "./objectStoreClient.server"; /** * Parsed storage URI with optional protocol prefix @@ -47,6 +53,115 @@ export function formatStorageUri(path: string, protocol?: string): string { return path; } +export const INVALID_PACKET_STORAGE_PATH = "Invalid packet storage path"; + +export type PacketPresignFailure = { + success: false; + error: string; + status?: number; +}; + +const PACKET_RELATIVE_PATH_BASE = "/__packet_base__"; + +function throwInvalidPacketStoragePath(): never { + throw new ServiceValidationError(INVALID_PACKET_STORAGE_PATH, 400); +} + +function assertRawPacketRelativePathSegments(path: string): void { + if (!path || path.includes("\\") || path.startsWith("/")) { + throwInvalidPacketStoragePath(); + } + + for (const segment of path.split("/")) { + if (segment === "" || segment === "." || segment === "..") { + throwInvalidPacketStoragePath(); + } + + if (segment.includes("%")) { + let decoded: string; + try { + decoded = decodeURIComponent(segment); + } catch { + throwInvalidPacketStoragePath(); + } + + if (decoded === "." || decoded === ".." || decoded.includes("/")) { + throwInvalidPacketStoragePath(); + } + } + } +} + +/** + * Normalize a packet-relative path using the same URL pathname resolution as object-store clients. + */ +export function normalizePacketRelativePath(path: string): string { + const url = new URL("https://trigger.invalid"); + url.pathname = `${PACKET_RELATIVE_PATH_BASE}/${path.replace(/^\/+/, "")}`; + + const prefix = `${PACKET_RELATIVE_PATH_BASE}/`; + if (!url.pathname.startsWith(prefix)) { + throwInvalidPacketStoragePath(); + } + + return url.pathname.slice(prefix.length); +} + +/** + * Ensure a full logical object-store key resolves under the packet prefix after URL normalization. + */ +export function assertPacketObjectStoreKeyUnderPrefix(key: string, packetPrefix: string): void { + const normalizedKeyPath = normalizeObjectStoreLogicalKeyPathname(key); + const normalizedPrefixPath = normalizeObjectStoreLogicalKeyPathname(packetPrefix); + + if ( + normalizedKeyPath !== normalizedPrefixPath && + !normalizedKeyPath.startsWith(`${normalizedPrefixPath}/`) + ) { + throwInvalidPacketStoragePath(); + } +} + +/** + * Validate a packet-relative path and return the canonical form used for object-store keys. + */ +export function resolveSafePacketRelativePath(path: string): string { + assertRawPacketRelativePathSegments(path); + const normalized = normalizePacketRelativePath(path); + assertRawPacketRelativePathSegments(normalized); + return normalized; +} + +/** + * Reject path traversal and other unsafe packet-relative storage paths before + * building object-store keys or presigned URLs. + */ +export function assertSafePacketRelativePath(path: string): void { + resolveSafePacketRelativePath(path); +} + +function buildPacketObjectStoreKey( + projectRef: string, + envSlug: string, + relativePath: string +): string { + const safeRelativePath = resolveSafePacketRelativePath(relativePath); + const prefix = `packets/${projectRef}/${envSlug}`; + const key = `${prefix}/${safeRelativePath}`; + assertPacketObjectStoreKeyUnderPrefix(key, prefix); + return key; +} + +/** JSON response for packet presign failures (400 client error vs 500 internal). */ +export function jsonPacketPresignFailure(failure: PacketPresignFailure) { + const status = failure.status ?? 500; + if (status === 400) { + return json({ error: failure.error }, { status: 400 }); + } + + return json({ error: `Failed to generate presigned URL: ${failure.error}` }, { status: 500 }); +} + /** * Get object storage configuration for a given protocol. * Returns a config if baseUrl is set, even without explicit credentials — @@ -134,14 +249,20 @@ export async function uploadPacketToObjectStore( throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`); } - const key = `packets/${environment.project.externalRef}/${environment.slug}/${filename}`; + const { path } = parseStorageUri(filename); + const safePath = resolveSafePacketRelativePath(path); + const key = buildPacketObjectStoreKey( + environment.project.externalRef, + environment.slug, + safePath + ); logger.debug("Uploading to object store", { key, protocol: protocol || "default" }); await client.putObject(key, data, contentType); - // Return filename with protocol prefix if specified - return formatStorageUri(filename, protocol); + // Return canonical storage URI (path only in the key; protocol prefix applied here) + return formatStorageUri(safePath, protocol); } export async function downloadPacketFromObjectStore( @@ -162,14 +283,18 @@ export async function downloadPacketFromObjectStore( } const { protocol, path } = parseStorageUri(packet.data); + const key = buildPacketObjectStoreKey( + environment.project.externalRef, + environment.slug, + path + ); + const client = getObjectStoreClient(protocol); if (!client) { throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`); } - const key = `packets/${environment.project.externalRef}/${environment.slug}/${path}`; - logger.debug("Downloading from object store", { key, protocol: protocol || "default" }); const data = await client.getObject(key); @@ -220,10 +345,7 @@ export async function generatePresignedRequest( method: "PUT" | "GET" = "PUT", options?: GeneratePacketPresignOptions ): Promise< - | { - success: false; - error: string; - } + | PacketPresignFailure | { success: true; request: Request; @@ -237,6 +359,21 @@ export async function generatePresignedRequest( options?.forceNoPrefix ); + let safePath: string; + try { + safePath = resolveSafePacketRelativePath(path); + } catch (error) { + if (error instanceof ServiceValidationError) { + return { + success: false, + error: error.message, + status: error.status ?? 400, + }; + } + + throw error; + } + const config = getObjectStoreConfig(storeProtocol); if (!config?.baseUrl) { return { @@ -253,7 +390,7 @@ export async function generatePresignedRequest( }; } - const key = `packets/${projectRef}/${envSlug}/${path}`; + const key = buildPacketObjectStoreKey(projectRef, envSlug, safePath); try { const url = await client.presign(key, method, 300); // 5 minutes @@ -266,7 +403,7 @@ export async function generatePresignedRequest( protocol: storeProtocol || "default", }); - const storagePath = method === "PUT" ? formatStorageUri(path, storeProtocol) : undefined; + const storagePath = method === "PUT" ? formatStorageUri(safePath, storeProtocol) : undefined; return { success: true, @@ -276,9 +413,7 @@ export async function generatePresignedRequest( } catch (error) { return { success: false, - error: `Failed to generate presigned URL: ${ - error instanceof Error ? error.message : String(error) - }`, + error: error instanceof Error ? error.message : String(error), }; } } @@ -289,23 +424,14 @@ export async function generatePresignedUrl( filename: string, method: "PUT" | "GET" = "PUT", options?: GeneratePacketPresignOptions -): Promise< - | { - success: false; - error: string; - } - | { - success: true; - url: string; - storagePath?: string; - } -> { +): Promise { const signed = await generatePresignedRequest(projectRef, envSlug, filename, method, options); if (!signed.success) { return { success: false, error: signed.error, + status: signed.status, }; } diff --git a/apps/webapp/app/v3/objectStoreClient.server.ts b/apps/webapp/app/v3/objectStoreClient.server.ts index d7a85924220..9999a40b79e 100644 --- a/apps/webapp/app/v3/objectStoreClient.server.ts +++ b/apps/webapp/app/v3/objectStoreClient.server.ts @@ -2,6 +2,16 @@ import { AwsClient } from "aws4fetch"; import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { getSignedUrl } from "@aws-sdk/s3-request-presigner"; +/** + * Normalize a logical object-store key the same way Aws4FetchClient assigns URL pathnames. + * Decodes percent-escapes and resolves `.` / `..` segments before the request is signed. + */ +export function normalizeObjectStoreLogicalKeyPathname(logicalKey: string): string { + const url = new URL("https://trigger.invalid"); + url.pathname = `/${logicalKey.replace(/^\/+/, "")}`; + return url.pathname; +} + interface IObjectStoreClient { putObject(key: string, body: ReadableStream | string, contentType: string): Promise; getObject(key: string): Promise; @@ -32,7 +42,7 @@ class Aws4FetchClient implements IObjectStoreClient { private buildUrl(key: string): string { const url = new URL(this.config.baseUrl); - url.pathname = `/${key}`; + url.pathname = normalizeObjectStoreLogicalKeyPathname(key); return url.toString(); } @@ -59,7 +69,7 @@ class Aws4FetchClient implements IObjectStoreClient { async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise { const url = new URL(this.config.baseUrl); - url.pathname = `/${key}`; + url.pathname = normalizeObjectStoreLogicalKeyPathname(key); url.searchParams.set("X-Amz-Expires", String(expiresIn)); const signed = await this.awsClient.sign(new Request(url, { method }), { @@ -100,7 +110,7 @@ class AwsSdkClient implements IObjectStoreClient { private logicalObjectUrl(logicalKey: string): string { const url = new URL(this.config.baseUrl); - url.pathname = `/${logicalKey}`; + url.pathname = normalizeObjectStoreLogicalKeyPathname(logicalKey); return url.href; } diff --git a/apps/webapp/test/objectStore.test.ts b/apps/webapp/test/objectStore.test.ts index bbd12d23124..bdfd1f7cfb1 100644 --- a/apps/webapp/test/objectStore.test.ts +++ b/apps/webapp/test/objectStore.test.ts @@ -4,13 +4,21 @@ import { type PrismaClient } from "@trigger.dev/database"; import { afterAll, describe, expect, it, vi } from "vitest"; import { env } from "~/env.server"; import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server"; +import { ServiceValidationError } from "~/v3/services/common.server"; +import { normalizeObjectStoreLogicalKeyPathname } from "~/v3/objectStoreClient.server"; import { + assertPacketObjectStoreKeyUnderPrefix, + assertSafePacketRelativePath, downloadPacketFromObjectStore, formatStorageUri, generatePresignedRequest, generatePresignedUrl, hasObjectStoreClient, + INVALID_PACKET_STORAGE_PATH, + jsonPacketPresignFailure, + normalizePacketRelativePath, parseStorageUri, + resolveSafePacketRelativePath, resolveStoreProtocolForPacketPresign, uploadPacketToObjectStore, } from "~/v3/objectStore.server"; @@ -106,6 +114,182 @@ describe("Object Storage", () => { }); }); + describe("assertSafePacketRelativePath", () => { + const expectInvalidPath = (path: string) => { + try { + assertSafePacketRelativePath(path); + expect.unreachable("expected path validation to throw"); + } catch (error) { + expect(error).toBeInstanceOf(ServiceValidationError); + expect((error as ServiceValidationError).message).toBe(INVALID_PACKET_STORAGE_PATH); + expect((error as ServiceValidationError).status).toBe(400); + } + }; + + it.each([ + "../file.json", + "../../other-env/file.json", + "foo/../bar.json", + "/absolute/path.json", + "foo//bar.json", + "foo\\bar.json", + ".", + "..", + ])("rejects unsafe path %s with 400 ServiceValidationError", (path) => { + expectInvalidPath(path); + }); + + it.each([ + "run/%2e%2e/secret.json", + "%2e%2e/secret.json", + "%2E%2E/secret.json", + "run/%2e./payload.json", + "run/%2e%2e", + "foo/%2e/bar.json", + "foo/%2E/bar.json", + ])("rejects percent-encoded traversal %s", (path) => { + expectInvalidPath(path); + }); + + it("allows normal packet paths", () => { + expect(() => assertSafePacketRelativePath("run_123/payload.json")).not.toThrow(); + expect(resolveSafePacketRelativePath("run_123/payload.json")).toBe("run_123/payload.json"); + }); + + it("rejects traversal in protocol-prefixed storage URIs", () => { + expect(() => assertSafePacketRelativePath(parseStorageUri("s3://../evil.json").path)).toThrow( + ServiceValidationError + ); + }); + }); + + describe("normalizePacketRelativePath", () => { + it("collapses redundant segments in safe paths", () => { + expect(normalizePacketRelativePath("run/./payload.json")).toBe("run/payload.json"); + }); + + it("collapses encoded parent segments when used without segment decoding (why decode is required)", () => { + expect(normalizePacketRelativePath("run/%2e%2e/secret.json")).toBe("secret.json"); + }); + }); + + describe("double-encoded traversal segments", () => { + it("does not decode %252e%252e to .. in URL pathname (stays literal; prefix check still holds)", () => { + const path = "%252e%252e/secret.json"; + expect(() => assertSafePacketRelativePath(path)).not.toThrow(); + + const key = `packets/proj_ref/dev/${path}`; + const normalized = normalizeObjectStoreLogicalKeyPathname(key); + expect(normalized).toBe(`/packets/proj_ref/dev/${path}`); + expect(() => assertPacketObjectStoreKeyUnderPrefix(key, "packets/proj_ref/dev")).not.toThrow(); + }); + }); + + describe("assertPacketObjectStoreKeyUnderPrefix", () => { + const prefix = "packets/proj_ref/dev"; + + it("accepts keys under the packet prefix after URL normalization", () => { + expect(() => + assertPacketObjectStoreKeyUnderPrefix(`${prefix}/run_123/payload.json`, prefix) + ).not.toThrow(); + }); + + it("rejects keys whose normalized pathname escapes the packet prefix", () => { + const traversalKey = `${prefix}/%2e%2e/secret.json`; + const normalized = normalizeObjectStoreLogicalKeyPathname(traversalKey); + expect(normalized).toBe("/packets/proj_ref/secret.json"); + + expect(() => assertPacketObjectStoreKeyUnderPrefix(traversalKey, prefix)).toThrow( + ServiceValidationError + ); + }); + + it("rejects env-level traversal via encoded parent segments", () => { + const traversalKey = `${prefix}/%2e%2e/secret.json`; + expect(normalizeObjectStoreLogicalKeyPathname(traversalKey)).toBe("/packets/proj_ref/secret.json"); + + expect(() => assertPacketObjectStoreKeyUnderPrefix(traversalKey, prefix)).toThrow( + ServiceValidationError + ); + }); + }); + + describe("normalizeObjectStoreLogicalKeyPathname (Aws4FetchClient behavior)", () => { + it("decodes %2e%2e segments into parent directory traversal", () => { + const key = "packets/proj_ref/dev/run/%2e%2e/secret.json"; + expect(normalizeObjectStoreLogicalKeyPathname(key)).toBe( + "/packets/proj_ref/dev/secret.json" + ); + }); + }); + + describe("jsonPacketPresignFailure", () => { + it("returns 400 for invalid packet path validation failures", async () => { + const response = jsonPacketPresignFailure({ + success: false, + error: INVALID_PACKET_STORAGE_PATH, + status: 400, + }); + expect(response.status).toBe(400); + expect(await response.json()).toEqual({ error: INVALID_PACKET_STORAGE_PATH }); + }); + + it("returns 500 for other presign failures", async () => { + const response = jsonPacketPresignFailure({ + success: false, + error: "Object store is not configured for protocol: default", + }); + expect(response.status).toBe(500); + expect(await response.json()).toEqual({ + error: "Failed to generate presigned URL: Object store is not configured for protocol: default", + }); + }); + + it("prefixes unprefixed internal errors exactly once (no double prefix)", async () => { + const response = jsonPacketPresignFailure({ + success: false, + error: "Access Denied", + }); + const body = await response.json(); + expect(body).toEqual({ error: "Failed to generate presigned URL: Access Denied" }); + expect(body.error).not.toMatch(/Failed to generate presigned URL: Failed to generate/); + }); + }); + + describe("generatePresignedUrl path validation", () => { + it.each([ + "../file.json", + "../../other-env/file.json", + "foo/../bar.json", + "/absolute/path.json", + "run/%2e%2e/secret.json", + "%2e%2e/secret.json", + "%2E%2E/secret.json", + ])( + "returns 400 failure for unsafe path %s without calling object store", + async (filename) => { + const result = await generatePresignedUrl("proj_test", "dev", filename, "PUT"); + expect(result.success).toBe(false); + if (result.success) throw new Error("expected presign to fail"); + expect(result.error).toBe(INVALID_PACKET_STORAGE_PATH); + expect(result.status).toBe(400); + } + ); + + it("allows presign for valid packet paths when object store is not configured", async () => { + env.OBJECT_STORE_BASE_URL = undefined; + env.OBJECT_STORE_ACCESS_KEY_ID = undefined; + env.OBJECT_STORE_SECRET_ACCESS_KEY = undefined; + env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined; + + const result = await generatePresignedUrl("proj_test", "dev", "run_123/payload.json", "PUT"); + expect(result.success).toBe(false); + if (result.success) throw new Error("expected presign to fail"); + expect(result.error).toContain("Object store is not configured"); + expect(result.status).toBeUndefined(); + }); + }); + describe("resolveStoreProtocolForPacketPresign", () => { afterEach(() => { env.OBJECT_STORE_DEFAULT_PROTOCOL = originalEnvObj.OBJECT_STORE_DEFAULT_PROTOCOL; From eff6e64bd6290814683874ba7eaf48ab7b1a2922 Mon Sep 17 00:00:00 2001 From: nicktrn <55853254+nicktrn@users.noreply.github.com> Date: Thu, 4 Jun 2026 18:10:24 +0100 Subject: [PATCH 2/2] Update validate-packet-storage-paths.md --- .server-changes/validate-packet-storage-paths.md | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/.server-changes/validate-packet-storage-paths.md b/.server-changes/validate-packet-storage-paths.md index 0bd594e987e..d0e2f68d3ac 100644 --- a/.server-changes/validate-packet-storage-paths.md +++ b/.server-changes/validate-packet-storage-paths.md @@ -3,16 +3,4 @@ area: webapp type: fix --- -Validate packet-relative storage paths before building object-store keys or presigned URLs. Rejects: - -- an empty path -- absolute paths (leading `/`) -- backslashes (`\`) -- empty path segments (e.g. `foo//bar`, leading or trailing `/`) -- `.` path segments (e.g. `.`, `foo/./bar`) -- `..` path segments (path traversal, e.g. `../file`, `foo/../bar`) -- percent-encoded `.` / `..` segments (e.g. `%2e%2e`, `%2E%2E`, `%2e.`) - -After segment checks, paths are normalized with the same URL pathname resolution used by `Aws4FetchClient`, and the full object-store key must remain under `packets/{projectRef}/{envSlug}/` after that normalization. - -Applied in `uploadPacketToObjectStore`, `downloadPacketFromObjectStore`, and `generatePresignedRequest`. `Aws4FetchClient` uses shared `normalizeObjectStoreLogicalKeyPathname` for presign/PUT/GET URLs. +Validate packet-relative storage paths before building object-store keys or presigned URLs.