1+ import { json } from "@remix-run/server-runtime" ;
12import { type IOPacket } from "@trigger.dev/core/v3" ;
23import { env } from "~/env.server" ;
34import { type AuthenticatedEnvironment } from "~/services/apiAuth.server" ;
45import { logger } from "~/services/logger.server" ;
6+ import { ServiceValidationError } from "~/v3/services/common.server" ;
57import { 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
147268export 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
0 commit comments