Skip to content

Commit cd5487e

Browse files
Ekaterina BulatovaEkaterina Bulatova
authored andcommitted
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.
1 parent 4ea3ef1 commit cd5487e

5 files changed

Lines changed: 154 additions & 20 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. Rejects path traversal (`..`), absolute paths, backslashes, and empty segments to prevent escaping a project/environment's packet prefix. Applied in `uploadPacketToObjectStore`, `downloadPacketFromObjectStore`, and `generatePresignedRequest`.

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: 58 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
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";
68
import { ObjectStoreClient, type ObjectStoreClientConfig } from "./objectStoreClient.server";
79

@@ -47,6 +49,40 @@ export function formatStorageUri(path: string, protocol?: string): string {
4749
return path;
4850
}
4951

52+
export const INVALID_PACKET_STORAGE_PATH = "Invalid packet storage path";
53+
54+
export type PacketPresignFailure = {
55+
success: false;
56+
error: string;
57+
status?: number;
58+
};
59+
60+
/**
61+
* Reject path traversal and other unsafe packet-relative storage paths before
62+
* building object-store keys or presigned URLs.
63+
*/
64+
export function assertSafePacketRelativePath(path: string): void {
65+
if (!path || path.includes("\\") || path.startsWith("/")) {
66+
throw new ServiceValidationError(INVALID_PACKET_STORAGE_PATH, 400);
67+
}
68+
69+
for (const segment of path.split("/")) {
70+
if (segment === "" || segment === "." || segment === "..") {
71+
throw new ServiceValidationError(INVALID_PACKET_STORAGE_PATH, 400);
72+
}
73+
}
74+
}
75+
76+
/** JSON response for packet presign failures (400 client error vs 500 internal). */
77+
export function jsonPacketPresignFailure(failure: PacketPresignFailure) {
78+
const status = failure.status ?? 500;
79+
if (status === 400) {
80+
return json({ error: failure.error }, { status: 400 });
81+
}
82+
83+
return json({ error: `Failed to generate presigned URL: ${failure.error}` }, { status: 500 });
84+
}
85+
5086
/**
5187
* Get object storage configuration for a given protocol.
5288
* Returns a config if baseUrl is set, even without explicit credentials —
@@ -134,6 +170,9 @@ export async function uploadPacketToObjectStore(
134170
throw new Error(`Object store is not configured for protocol: ${protocol || "default"}`);
135171
}
136172

173+
const { path } = parseStorageUri(filename);
174+
assertSafePacketRelativePath(path);
175+
137176
const key = `packets/${environment.project.externalRef}/${environment.slug}/${filename}`;
138177

139178
logger.debug("Uploading to object store", { key, protocol: protocol || "default" });
@@ -162,6 +201,8 @@ export async function downloadPacketFromObjectStore(
162201
}
163202

164203
const { protocol, path } = parseStorageUri(packet.data);
204+
assertSafePacketRelativePath(path);
205+
165206
const client = getObjectStoreClient(protocol);
166207

167208
if (!client) {
@@ -220,10 +261,7 @@ export async function generatePresignedRequest(
220261
method: "PUT" | "GET" = "PUT",
221262
options?: GeneratePacketPresignOptions
222263
): Promise<
223-
| {
224-
success: false;
225-
error: string;
226-
}
264+
| PacketPresignFailure
227265
| {
228266
success: true;
229267
request: Request;
@@ -237,6 +275,20 @@ export async function generatePresignedRequest(
237275
options?.forceNoPrefix
238276
);
239277

278+
try {
279+
assertSafePacketRelativePath(path);
280+
} catch (error) {
281+
if (error instanceof ServiceValidationError) {
282+
return {
283+
success: false,
284+
error: error.message,
285+
status: error.status ?? 400,
286+
};
287+
}
288+
289+
throw error;
290+
}
291+
240292
const config = getObjectStoreConfig(storeProtocol);
241293
if (!config?.baseUrl) {
242294
return {
@@ -289,23 +341,14 @@ export async function generatePresignedUrl(
289341
filename: string,
290342
method: "PUT" | "GET" = "PUT",
291343
options?: GeneratePacketPresignOptions
292-
): Promise<
293-
| {
294-
success: false;
295-
error: string;
296-
}
297-
| {
298-
success: true;
299-
url: string;
300-
storagePath?: string;
301-
}
302-
> {
344+
): Promise<PacketPresignFailure | { success: true; url: string; storagePath?: string }> {
303345
const signed = await generatePresignedRequest(projectRef, envSlug, filename, method, options);
304346

305347
if (!signed.success) {
306348
return {
307349
success: false,
308350
error: signed.error,
351+
status: signed.status,
309352
};
310353
}
311354

apps/webapp/test/objectStore.test.ts

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,16 @@ import { type PrismaClient } from "@trigger.dev/database";
44
import { afterAll, describe, expect, it, vi } from "vitest";
55
import { env } from "~/env.server";
66
import { processWaitpointCompletionPacket } from "~/runEngine/concerns/waitpointCompletionPacket.server";
7+
import { ServiceValidationError } from "~/v3/services/common.server";
78
import {
9+
assertSafePacketRelativePath,
810
downloadPacketFromObjectStore,
911
formatStorageUri,
1012
generatePresignedRequest,
1113
generatePresignedUrl,
1214
hasObjectStoreClient,
15+
INVALID_PACKET_STORAGE_PATH,
16+
jsonPacketPresignFailure,
1317
parseStorageUri,
1418
resolveStoreProtocolForPacketPresign,
1519
uploadPacketToObjectStore,
@@ -106,6 +110,87 @@ describe("Object Storage", () => {
106110
});
107111
});
108112

113+
describe("assertSafePacketRelativePath", () => {
114+
it.each([
115+
"../file.json",
116+
"../../other-env/file.json",
117+
"foo/../bar.json",
118+
"/absolute/path.json",
119+
"foo//bar.json",
120+
"foo\\bar.json",
121+
".",
122+
"..",
123+
])("rejects unsafe path %s with 400 ServiceValidationError", (path) => {
124+
try {
125+
assertSafePacketRelativePath(path);
126+
expect.unreachable("expected path validation to throw");
127+
} catch (error) {
128+
expect(error).toBeInstanceOf(ServiceValidationError);
129+
expect((error as ServiceValidationError).message).toBe(INVALID_PACKET_STORAGE_PATH);
130+
expect((error as ServiceValidationError).status).toBe(400);
131+
}
132+
});
133+
134+
it("allows normal packet paths", () => {
135+
expect(() => assertSafePacketRelativePath("run_123/payload.json")).not.toThrow();
136+
});
137+
138+
it("rejects traversal in protocol-prefixed storage URIs", () => {
139+
expect(() => assertSafePacketRelativePath(parseStorageUri("s3://../evil.json").path)).toThrow(
140+
ServiceValidationError
141+
);
142+
});
143+
});
144+
145+
describe("jsonPacketPresignFailure", () => {
146+
it("returns 400 for invalid packet path validation failures", async () => {
147+
const response = jsonPacketPresignFailure({
148+
success: false,
149+
error: INVALID_PACKET_STORAGE_PATH,
150+
status: 400,
151+
});
152+
expect(response.status).toBe(400);
153+
expect(await response.json()).toEqual({ error: INVALID_PACKET_STORAGE_PATH });
154+
});
155+
156+
it("returns 500 for other presign failures", async () => {
157+
const response = jsonPacketPresignFailure({
158+
success: false,
159+
error: "Object store is not configured for protocol: default",
160+
});
161+
expect(response.status).toBe(500);
162+
expect(await response.json()).toEqual({
163+
error: "Failed to generate presigned URL: Object store is not configured for protocol: default",
164+
});
165+
});
166+
});
167+
168+
describe("generatePresignedUrl path validation", () => {
169+
it.each(["../file.json", "../../other-env/file.json", "foo/../bar.json", "/absolute/path.json"])(
170+
"returns 400 failure for unsafe path %s without calling object store",
171+
async (filename) => {
172+
const result = await generatePresignedUrl("proj_test", "dev", filename, "PUT");
173+
expect(result.success).toBe(false);
174+
if (result.success) throw new Error("expected presign to fail");
175+
expect(result.error).toBe(INVALID_PACKET_STORAGE_PATH);
176+
expect(result.status).toBe(400);
177+
}
178+
);
179+
180+
it("allows presign for valid packet paths when object store is not configured", async () => {
181+
env.OBJECT_STORE_BASE_URL = undefined;
182+
env.OBJECT_STORE_ACCESS_KEY_ID = undefined;
183+
env.OBJECT_STORE_SECRET_ACCESS_KEY = undefined;
184+
env.OBJECT_STORE_DEFAULT_PROTOCOL = undefined;
185+
186+
const result = await generatePresignedUrl("proj_test", "dev", "run_123/payload.json", "PUT");
187+
expect(result.success).toBe(false);
188+
if (result.success) throw new Error("expected presign to fail");
189+
expect(result.error).toContain("Object store is not configured");
190+
expect(result.status).toBeUndefined();
191+
});
192+
});
193+
109194
describe("resolveStoreProtocolForPacketPresign", () => {
110195
afterEach(() => {
111196
env.OBJECT_STORE_DEFAULT_PROTOCOL = originalEnvObj.OBJECT_STORE_DEFAULT_PROTOCOL;

0 commit comments

Comments
 (0)