Skip to content

Commit db4074d

Browse files
authored
fix(webapp): validate packet storage paths (#3830)
## Summary This PR adds packet path validation before key construction and presigning. Invalid paths are rejected before reaching either object-store client implementation, ensuring consistent behavior regardless of the underlying storage configuration.
1 parent 8c9fee3 commit db4074d

6 files changed

Lines changed: 360 additions & 34 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: fix
4+
---
5+
6+
Validate packet-relative storage paths before building object-store keys or presigned URLs.

apps/webapp/app/routes/api.v1.packets.$.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { authenticateApiRequest } from "~/services/apiAuth.server";
55
import { createLoaderApiRoute } from "~/services/routeBuilders/apiBuilder.server";
6-
import { generatePresignedUrl } from "~/v3/objectStore.server";
6+
import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server";
77

88
const ParamsSchema = z.object({
99
"*": z.string(),
@@ -34,7 +34,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
3434
);
3535

3636
if (!signed.success) {
37-
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
37+
return jsonPacketPresignFailure(signed);
3838
}
3939

4040
// Caller can now use this URL to upload to that object.
@@ -59,7 +59,7 @@ export const loader = createLoaderApiRoute(
5959
);
6060

6161
if (!signed.success) {
62-
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
62+
return jsonPacketPresignFailure(signed);
6363
}
6464

6565
// Caller can now use this URL to fetch that object.

apps/webapp/app/routes/api.v2.packets.$.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { ActionFunctionArgs } from "@remix-run/server-runtime";
22
import { json } from "@remix-run/server-runtime";
33
import { z } from "zod";
44
import { authenticateApiRequest } from "~/services/apiAuth.server";
5-
import { generatePresignedUrl } from "~/v3/objectStore.server";
5+
import { generatePresignedUrl, jsonPacketPresignFailure } from "~/v3/objectStore.server";
66

77
const ParamsSchema = z.object({
88
"*": z.string(),
@@ -34,7 +34,7 @@ export async function action({ request, params }: ActionFunctionArgs) {
3434
);
3535

3636
if (!signed.success) {
37-
return json({ error: `Failed to generate presigned URL: ${signed.error}` }, { status: 500 });
37+
return jsonPacketPresignFailure(signed);
3838
}
3939

4040
if (signed.storagePath === undefined) {

apps/webapp/app/v3/objectStore.server.ts

Lines changed: 152 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
1+
import { json } from "@remix-run/server-runtime";
12
import { type IOPacket } from "@trigger.dev/core/v3";
23
import { env } from "~/env.server";
34
import { type AuthenticatedEnvironment } from "~/services/apiAuth.server";
45
import { logger } from "~/services/logger.server";
6+
import { ServiceValidationError } from "~/v3/services/common.server";
57
import { singleton } from "~/utils/singleton";
6-
import { ObjectStoreClient, type ObjectStoreClientConfig } from "./objectStoreClient.server";
8+
import {
9+
normalizeObjectStoreLogicalKeyPathname,
10+
ObjectStoreClient,
11+
type ObjectStoreClientConfig,
12+
} from "./objectStoreClient.server";
713

814
/**
915
* Parsed storage URI with optional protocol prefix
@@ -47,6 +53,115 @@ export function formatStorageUri(path: string, protocol?: string): string {
4753
return path;
4854
}
4955

56+
export const INVALID_PACKET_STORAGE_PATH = "Invalid packet storage path";
57+
58+
export type PacketPresignFailure = {
59+
success: false;
60+
error: string;
61+
status?: number;
62+
};
63+
64+
const PACKET_RELATIVE_PATH_BASE = "/__packet_base__";
65+
66+
function throwInvalidPacketStoragePath(): never {
67+
throw new ServiceValidationError(INVALID_PACKET_STORAGE_PATH, 400);
68+
}
69+
70+
function assertRawPacketRelativePathSegments(path: string): void {
71+
if (!path || path.includes("\\") || path.startsWith("/")) {
72+
throwInvalidPacketStoragePath();
73+
}
74+
75+
for (const segment of path.split("/")) {
76+
if (segment === "" || segment === "." || segment === "..") {
77+
throwInvalidPacketStoragePath();
78+
}
79+
80+
if (segment.includes("%")) {
81+
let decoded: string;
82+
try {
83+
decoded = decodeURIComponent(segment);
84+
} catch {
85+
throwInvalidPacketStoragePath();
86+
}
87+
88+
if (decoded === "." || decoded === ".." || decoded.includes("/")) {
89+
throwInvalidPacketStoragePath();
90+
}
91+
}
92+
}
93+
}
94+
95+
/**
96+
* Normalize a packet-relative path using the same URL pathname resolution as object-store clients.
97+
*/
98+
export function normalizePacketRelativePath(path: string): string {
99+
const url = new URL("https://trigger.invalid");
100+
url.pathname = `${PACKET_RELATIVE_PATH_BASE}/${path.replace(/^\/+/, "")}`;
101+
102+
const prefix = `${PACKET_RELATIVE_PATH_BASE}/`;
103+
if (!url.pathname.startsWith(prefix)) {
104+
throwInvalidPacketStoragePath();
105+
}
106+
107+
return url.pathname.slice(prefix.length);
108+
}
109+
110+
/**
111+
* Ensure a full logical object-store key resolves under the packet prefix after URL normalization.
112+
*/
113+
export function assertPacketObjectStoreKeyUnderPrefix(key: string, packetPrefix: string): void {
114+
const normalizedKeyPath = normalizeObjectStoreLogicalKeyPathname(key);
115+
const normalizedPrefixPath = normalizeObjectStoreLogicalKeyPathname(packetPrefix);
116+
117+
if (
118+
normalizedKeyPath !== normalizedPrefixPath &&
119+
!normalizedKeyPath.startsWith(`${normalizedPrefixPath}/`)
120+
) {
121+
throwInvalidPacketStoragePath();
122+
}
123+
}
124+
125+
/**
126+
* Validate a packet-relative path and return the canonical form used for object-store keys.
127+
*/
128+
export function resolveSafePacketRelativePath(path: string): string {
129+
assertRawPacketRelativePathSegments(path);
130+
const normalized = normalizePacketRelativePath(path);
131+
assertRawPacketRelativePathSegments(normalized);
132+
return normalized;
133+
}
134+
135+
/**
136+
* Reject path traversal and other unsafe packet-relative storage paths before
137+
* building object-store keys or presigned URLs.
138+
*/
139+
export function assertSafePacketRelativePath(path: string): void {
140+
resolveSafePacketRelativePath(path);
141+
}
142+
143+
function buildPacketObjectStoreKey(
144+
projectRef: string,
145+
envSlug: string,
146+
relativePath: string
147+
): string {
148+
const safeRelativePath = resolveSafePacketRelativePath(relativePath);
149+
const prefix = `packets/${projectRef}/${envSlug}`;
150+
const key = `${prefix}/${safeRelativePath}`;
151+
assertPacketObjectStoreKeyUnderPrefix(key, prefix);
152+
return key;
153+
}
154+
155+
/** JSON response for packet presign failures (400 client error vs 500 internal). */
156+
export function jsonPacketPresignFailure(failure: PacketPresignFailure) {
157+
const status = failure.status ?? 500;
158+
if (status === 400) {
159+
return json({ error: failure.error }, { status: 400 });
160+
}
161+
162+
return json({ error: `Failed to generate presigned URL: ${failure.error}` }, { status: 500 });
163+
}
164+
50165
/**
51166
* Get object storage configuration for a given protocol.
52167
* Returns a config if baseUrl is set, even without explicit credentials —
@@ -134,14 +249,20 @@ export async function uploadPacketToObjectStore(
134249
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
135250
}
136251

137-
const key = `packets/${environment.project.externalRef}/${environment.slug}/${filename}`;
252+
const { path } = parseStorageUri(filename);
253+
const safePath = resolveSafePacketRelativePath(path);
254+
const key = buildPacketObjectStoreKey(
255+
environment.project.externalRef,
256+
environment.slug,
257+
safePath
258+
);
138259

139260
logger.debug("Uploading to object store", { key, protocol: protocol || "default" });
140261

141262
await client.putObject(key, data, contentType);
142263

143-
// Return filename with protocol prefix if specified
144-
return formatStorageUri(filename, protocol);
264+
// Return canonical storage URI (path only in the key; protocol prefix applied here)
265+
return formatStorageUri(safePath, protocol);
145266
}
146267

147268
export async function downloadPacketFromObjectStore(
@@ -162,14 +283,18 @@ export async function downloadPacketFromObjectStore(
162283
}
163284

164285
const { protocol, path } = parseStorageUri(packet.data);
286+
const key = buildPacketObjectStoreKey(
287+
environment.project.externalRef,
288+
environment.slug,
289+
path
290+
);
291+
165292
const client = getObjectStoreClient(protocol);
166293

167294
if (!client) {
168295
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
169296
}
170297

171-
const key = `packets/${environment.project.externalRef}/${environment.slug}/${path}`;
172-
173298
logger.debug("Downloading from object store", { key, protocol: protocol || "default" });
174299

175300
const data = await client.getObject(key);
@@ -220,10 +345,7 @@ export async function generatePresignedRequest(
220345
method: "PUT" | "GET" = "PUT",
221346
options?: GeneratePacketPresignOptions
222347
): Promise<
223-
| {
224-
success: false;
225-
error: string;
226-
}
348+
| PacketPresignFailure
227349
| {
228350
success: true;
229351
request: Request;
@@ -237,6 +359,21 @@ export async function generatePresignedRequest(
237359
options?.forceNoPrefix
238360
);
239361

362+
let safePath: string;
363+
try {
364+
safePath = resolveSafePacketRelativePath(path);
365+
} catch (error) {
366+
if (error instanceof ServiceValidationError) {
367+
return {
368+
success: false,
369+
error: error.message,
370+
status: error.status ?? 400,
371+
};
372+
}
373+
374+
throw error;
375+
}
376+
240377
const config = getObjectStoreConfig(storeProtocol);
241378
if (!config?.baseUrl) {
242379
return {
@@ -253,7 +390,7 @@ export async function generatePresignedRequest(
253390
};
254391
}
255392

256-
const key = `packets/${projectRef}/${envSlug}/${path}`;
393+
const key = buildPacketObjectStoreKey(projectRef, envSlug, safePath);
257394

258395
try {
259396
const url = await client.presign(key, method, 300); // 5 minutes
@@ -266,7 +403,7 @@ export async function generatePresignedRequest(
266403
protocol: storeProtocol || "default",
267404
});
268405

269-
const storagePath = method === "PUT" ? formatStorageUri(path, storeProtocol) : undefined;
406+
const storagePath = method === "PUT" ? formatStorageUri(safePath, storeProtocol) : undefined;
270407

271408
return {
272409
success: true,
@@ -276,9 +413,7 @@ export async function generatePresignedRequest(
276413
} catch (error) {
277414
return {
278415
success: false,
279-
error: `Failed to generate presigned URL: ${
280-
error instanceof Error ? error.message : String(error)
281-
}`,
416+
error: error instanceof Error ? error.message : String(error),
282417
};
283418
}
284419
}
@@ -289,23 +424,14 @@ export async function generatePresignedUrl(
289424
filename: string,
290425
method: "PUT" | "GET" = "PUT",
291426
options?: GeneratePacketPresignOptions
292-
): Promise<
293-
| {
294-
success: false;
295-
error: string;
296-
}
297-
| {
298-
success: true;
299-
url: string;
300-
storagePath?: string;
301-
}
302-
> {
427+
): Promise<PacketPresignFailure | { success: true; url: string; storagePath?: string }> {
303428
const signed = await generatePresignedRequest(projectRef, envSlug, filename, method, options);
304429

305430
if (!signed.success) {
306431
return {
307432
success: false,
308433
error: signed.error,
434+
status: signed.status,
309435
};
310436
}
311437

apps/webapp/app/v3/objectStoreClient.server.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,16 @@ import { AwsClient } from "aws4fetch";
22
import { GetObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3";
33
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
44

5+
/**
6+
* Normalize a logical object-store key the same way Aws4FetchClient assigns URL pathnames.
7+
* Decodes percent-escapes and resolves `.` / `..` segments before the request is signed.
8+
*/
9+
export function normalizeObjectStoreLogicalKeyPathname(logicalKey: string): string {
10+
const url = new URL("https://trigger.invalid");
11+
url.pathname = `/${logicalKey.replace(/^\/+/, "")}`;
12+
return url.pathname;
13+
}
14+
515
interface IObjectStoreClient {
616
putObject(key: string, body: ReadableStream | string, contentType: string): Promise<string>;
717
getObject(key: string): Promise<string>;
@@ -32,7 +42,7 @@ class Aws4FetchClient implements IObjectStoreClient {
3242

3343
private buildUrl(key: string): string {
3444
const url = new URL(this.config.baseUrl);
35-
url.pathname = `/${key}`;
45+
url.pathname = normalizeObjectStoreLogicalKeyPathname(key);
3646
return url.toString();
3747
}
3848

@@ -59,7 +69,7 @@ class Aws4FetchClient implements IObjectStoreClient {
5969

6070
async presign(key: string, method: "PUT" | "GET", expiresIn: number): Promise<string> {
6171
const url = new URL(this.config.baseUrl);
62-
url.pathname = `/${key}`;
72+
url.pathname = normalizeObjectStoreLogicalKeyPathname(key);
6373
url.searchParams.set("X-Amz-Expires", String(expiresIn));
6474

6575
const signed = await this.awsClient.sign(new Request(url, { method }), {
@@ -100,7 +110,7 @@ class AwsSdkClient implements IObjectStoreClient {
100110

101111
private logicalObjectUrl(logicalKey: string): string {
102112
const url = new URL(this.config.baseUrl);
103-
url.pathname = `/${logicalKey}`;
113+
url.pathname = normalizeObjectStoreLogicalKeyPathname(logicalKey);
104114
return url.href;
105115
}
106116

0 commit comments

Comments
 (0)