• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

supabase / storage / 27004330011

05 Jun 2026 08:28AM UTC coverage: 76.354% (+0.008%) from 76.346%
27004330011

Pull #1135

github

web-flow
Merge 172762a37 into d2e814bcc
Pull Request #1135: fix: always validate signed urls scope

4499 of 6458 branches covered (69.67%)

Branch coverage included in aggregate %.

16 of 17 new or added lines in 4 files covered. (94.12%)

1 existing line in 1 file now uncovered.

8847 of 11021 relevant lines covered (80.27%)

368.58 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

88.78
/src/storage/object.ts
1
import { randomUUID } from 'node:crypto'
2
import {
3
  isDownloadScopedToken,
4
  isUploadScopedToken,
5
  SIGNED_URL_SCOPE_DOWNLOAD,
6
  SIGNED_URL_SCOPE_UPLOAD,
7
  SignedToken,
8
  SignedUploadToken,
9
  SignedUrlScope,
10
  signJWT,
11
  verifyJWT,
12
} from '@internal/auth'
13
import { getJwtSecret } from '@internal/database'
14
import { ERRORS } from '@internal/errors'
15
import { StorageObjectLocator } from '@storage/locator'
16
import { Obj } from '@storage/schemas'
17
import { FastifyRequest } from 'fastify/types/request'
18
import { getConfig } from '../config'
19
import { ObjectMetadata, StorageBackendAdapter } from './backend'
20
import { Database, FindObjectFilters, SearchObjectOption } from './database'
21
import {
22
  ObjectAdminDelete,
23
  ObjectCreatedCopyEvent,
24
  ObjectCreatedMove,
25
  ObjectRemoved,
26
  ObjectRemovedMove,
27
  ObjectUpdatedMetadata,
28
} from './events'
29
import { mustBeValidKey } from './limits'
30
import { CanUploadMetadata, fileUploadFromRequest, Uploader, UploadRequest } from './uploader'
31

32
const { requestUrlLengthLimit } = getConfig()
47✔
33

34
interface CopyObjectParams {
35
  sourceKey: string
36
  destinationBucket: string
37
  destinationKey: string
38
  owner?: string
39
  copyMetadata?: boolean
40
  upsert?: boolean
41
  uploadType: 'standard' | 's3' | 'resumable'
42
  metadata?: {
43
    cacheControl?: string
44
    mimetype?: string
45
  }
46
  userMetadata?: Record<string, unknown>
47
  conditions?: {
48
    ifMatch?: string
49
    ifNoneMatch?: string
50
    ifModifiedSince?: Date
51
    ifUnmodifiedSince?: Date
52
  }
53
}
54
export interface ListObjectsV2Result {
55
  folders: Obj[]
56
  objects: Obj[]
57
  hasNext: boolean
58
  nextCursor?: string
59
  nextCursorKey?: string
60
}
61

62
/**
63
 * ObjectStorage
64
 * interact with remote objects and database state
65
 */
66
export class ObjectStorage {
67
  protected readonly uploader: Uploader
68

69
  constructor(
70
    private readonly backend: StorageBackendAdapter,
637✔
71
    private readonly db: Database,
637✔
72
    private readonly location: StorageObjectLocator,
637✔
73
    private readonly bucketId: string
637✔
74
  ) {
75
    this.uploader = new Uploader(backend, db, location)
637✔
76
  }
77

78
  /**
79
   * Impersonate any subsequent chained operations
80
   * as superUser bypassing RLS rules
81
   */
82
  asSuperUser() {
83
    return new ObjectStorage(this.backend, this.db.asSuperUser(), this.location, this.bucketId)
×
84
  }
85

86
  async uploadFromRequest(
87
    request: FastifyRequest,
88
    file: {
89
      objectName: string
90
      owner?: string
91
      isUpsert: boolean
92
      signal?: AbortSignal
93
    }
94
  ) {
95
    const bucket = await this.db
189✔
96
      .asSuperUser()
97
      .findBucketById(this.bucketId, 'id, file_size_limit, allowed_mime_types')
98

99
    const uploadRequest = await fileUploadFromRequest(request, {
183✔
100
      objectName: file.objectName,
101
      fileSizeLimit: bucket.file_size_limit,
102
      allowedMimeTypes: bucket.allowed_mime_types || [],
360✔
103
    })
104

105
    return this.uploadNewObject({
177✔
106
      file: uploadRequest,
107
      objectName: file.objectName,
108
      owner: file.owner,
109
      isUpsert: Boolean(file.isUpsert),
110
      signal: file.signal,
111
      userMetadata: uploadRequest.userMetadata,
112
    })
113
  }
114

115
  /**
116
   * Upload a new object to a storage
117
   * @param request
118
   */
119
  async uploadNewObject(request: Omit<UploadRequest, 'bucketId' | 'uploadType'>) {
120
    mustBeValidKey(request.objectName)
178✔
121

122
    const path = `${this.bucketId}/${request.objectName}`
178✔
123

124
    const { metadata, obj } = await this.uploader.upload({
178✔
125
      ...request,
126
      bucketId: this.bucketId,
127
      uploadType: 'standard',
128
    })
129

130
    return { objectMetadata: metadata, path, id: obj.id }
145✔
131
  }
132

133
  /**
134
   * Deletes an object from the remote storage
135
   * and the database
136
   * @param objectName
137
   */
138
  async deleteObject(objectName: string) {
139
    const obj = await this.db.withTransaction(async (db) => {
24✔
140
      const obj = await db
24✔
141
        .asSuperUser()
142
        .findObject(this.bucketId, objectName, 'id,version,metadata', {
143
          forUpdate: true,
144
        })
145

146
      const deleted = await db.deleteObject(this.bucketId, objectName)
17✔
147

148
      if (!deleted) {
17✔
149
        throw ERRORS.AccessDenied('Access denied')
11✔
150
      }
151

152
      await this.backend.deleteObject(
6✔
153
        this.location.getRootLocation(),
154
        this.location.getKeyLocation({
155
          tenantId: this.db.tenantId,
156
          bucketId: this.bucketId,
157
          objectName,
158
        }),
159
        obj.version
160
      )
161

162
      return obj
6✔
163
    })
164

165
    await ObjectRemoved.sendWebhook({
6✔
166
      tenant: this.db.tenant(),
167
      name: objectName,
168
      version: obj.version,
169
      bucketId: this.bucketId,
170
      reqId: this.db.reqId,
171
      sbReqId: this.db.sbReqId,
172
      metadata: obj.metadata,
173
    })
174
  }
175

176
  /**
177
   * Deletes multiple objects from the remote storage
178
   * and the database
179
   * @param prefixes
180
   */
181
  async deleteObjects(prefixes: string[]) {
182
    let results: { name: string }[] = []
12✔
183

184
    for (let i = 0; i < prefixes.length; ) {
12✔
185
      const prefixesSubset: string[] = []
50✔
186
      let urlParamLength = 0
50✔
187

188
      for (; i < prefixes.length && urlParamLength < requestUrlLengthLimit; i++) {
50✔
189
        const prefix = prefixes[i]
10,025✔
190
        prefixesSubset.push(prefix)
10,025✔
191
        urlParamLength += encodeURIComponent(prefix).length + 9 // length of '%22%2C%22'
10,025✔
192
      }
193

194
      await this.db.withTransaction(async (db) => {
50✔
195
        const data = await db.deleteObjects(this.bucketId, prefixesSubset, 'name')
50✔
196

197
        if (data.length > 0) {
50✔
198
          results = results.concat(data)
46✔
199

200
          // if successfully deleted, delete from s3 too
201
          // todo: consider moving this to a queue
202
          const prefixesToDelete = data.reduce((all, { name, version }) => {
46✔
203
            all.push(
10,012✔
204
              this.location.getKeyLocation({
205
                tenantId: db.tenantId,
206
                bucketId: this.bucketId,
207
                objectName: name,
208
                version,
209
              })
210
            )
211

212
            if (version) {
10,012✔
213
              all.push(
10✔
214
                this.location.getKeyLocation({
215
                  tenantId: db.tenantId,
216
                  bucketId: this.bucketId,
217
                  objectName: name,
218
                  version,
219
                }) + '.info'
220
              )
221
            }
222
            return all
10,012✔
223
          }, [] as string[])
224

225
          await this.backend.deleteObjects(this.location.getRootLocation(), prefixesToDelete)
46✔
226

227
          await Promise.allSettled(
46✔
228
            data.map((object) =>
229
              ObjectRemoved.sendWebhook({
10,012✔
230
                tenant: db.tenant(),
231
                name: object.name,
232
                bucketId: this.bucketId,
233
                reqId: this.db.reqId,
234
                sbReqId: this.db.sbReqId,
235
                version: object.version,
236
                metadata: object.metadata,
237
              })
238
            )
239
          )
240
        }
241
      })
242
    }
243

244
    return results
50✔
245
  }
246

247
  /**
248
   * Updates object metadata in the database
249
   * @param objectName
250
   * @param metadata
251
   */
252
  async updateObjectMetadata(objectName: string, metadata: ObjectMetadata) {
253
    mustBeValidKey(objectName)
×
254

255
    const result = await this.db.updateObjectMetadata(this.bucketId, objectName, metadata)
×
256

257
    await ObjectUpdatedMetadata.sendWebhook({
×
258
      tenant: this.db.tenant(),
259
      name: objectName,
260
      version: result.version,
261
      bucketId: this.bucketId,
262
      metadata,
263
      reqId: this.db.reqId,
264
      sbReqId: this.db.sbReqId,
265
    })
266

267
    return result
×
268
  }
269

270
  /**
271
   * Updates the owner of an object in the database
272
   * @param objectName
273
   * @param owner
274
   */
275
  updateObjectOwner(objectName: string, owner?: string) {
276
    return this.db.updateObjectOwner(this.bucketId, objectName, owner)
×
277
  }
278

279
  /**
280
   * Finds an object by name
281
   * @param objectName
282
   * @param columns
283
   * @param filters
284
   */
285
  async findObject(objectName: string, columns = 'id', filters?: FindObjectFilters) {
95✔
286
    mustBeValidKey(objectName)
95✔
287

288
    return this.db.findObject(this.bucketId, objectName, columns, filters)
95✔
289
  }
290

291
  /**
292
   * Find multiple objects by name
293
   * @param objectNames
294
   * @param columns
295
   */
296
  async findObjects(objectNames: string[], columns = 'id') {
122✔
297
    return this.db.findObjects(this.bucketId, objectNames, columns)
122✔
298
  }
299

300
  /**
301
   * Copies an existing remote object to a given location
302
   * @param sourceKey
303
   * @param destinationBucket
304
   * @param destinationKey
305
   * @param owner
306
   * @param conditions
307
   * @param copyMetadata
308
   * @param upsert
309
   * @param fileMetadata
310
   * @param userMetadata
311
   */
312
  async copyObject({
313
    sourceKey,
314
    destinationBucket,
315
    destinationKey,
316
    owner,
317
    conditions,
318
    copyMetadata,
319
    upsert,
320
    uploadType,
321
    metadata: fileMetadata,
322
    userMetadata,
323
  }: CopyObjectParams) {
324
    mustBeValidKey(destinationKey)
22✔
325

326
    const newVersion = randomUUID()
22✔
327
    const s3SourceKey = this.location.getKeyLocation({
22✔
328
      tenantId: this.db.tenantId,
329
      bucketId: this.bucketId,
330
      objectName: sourceKey,
331
    })
332
    const s3DestinationKey = this.location.getKeyLocation({
22✔
333
      tenantId: this.db.tenantId,
334
      bucketId: destinationBucket,
335
      objectName: destinationKey,
336
    })
337

338
    // We check if the user has permission to copy the object to the destination key
339
    const originObject = await this.db.findObject(
22✔
340
      this.bucketId,
341
      sourceKey,
342
      'bucket_id,metadata,user_metadata,version'
343
    )
344

345
    const baseMetadata = originObject.metadata || {}
17!
346
    const destinationMetadata = copyMetadata
22✔
347
      ? baseMetadata
348
      : {
349
          ...baseMetadata,
350
          ...(fileMetadata || {}),
9✔
351
        }
352

353
    const destinationUserMetadata = copyMetadata ? originObject.user_metadata : userMetadata
22✔
354

355
    await this.uploader.canUpload({
22✔
356
      bucketId: destinationBucket,
357
      objectName: destinationKey,
358
      owner,
359
      isUpsert: upsert,
360
      userMetadata: destinationUserMetadata || undefined,
27✔
361
      metadata: destinationMetadata,
362
    })
363

364
    try {
14✔
365
      const copyResult = await this.backend.copyObject(
14✔
366
        this.location.getRootLocation(),
367
        s3SourceKey,
368
        originObject.version,
369
        s3DestinationKey,
370
        newVersion,
371
        destinationMetadata,
372
        conditions
373
      )
374

375
      const metadata = await this.backend.headObject(
14✔
376
        this.location.getRootLocation(),
377
        s3DestinationKey,
378
        newVersion
379
      )
380

381
      const destinationObject = await this.db.asSuperUser().withTransaction(async (db) => {
14✔
382
        await db.waitObjectLock(destinationBucket, destinationKey, undefined, {
14✔
383
          timeout: 3000,
384
        })
385

386
        const existingDestObject = await db.findObject(
14✔
387
          destinationBucket,
388
          destinationKey,
389
          'id,name,metadata,version,bucket_id',
390
          {
391
            dontErrorOnEmpty: true,
392
            forUpdate: true,
393
          }
394
        )
395

396
        const destinationObject = await db.upsertObject({
14✔
397
          ...originObject,
398
          bucket_id: destinationBucket,
399
          name: destinationKey,
400
          owner,
401
          metadata: {
402
            ...destinationMetadata,
403
            lastModified: copyResult.lastModified,
404
            eTag: copyResult.eTag,
405
          },
406
          user_metadata: destinationUserMetadata,
407
          version: newVersion,
408
        })
409

410
        if (existingDestObject) {
14✔
411
          await ObjectAdminDelete.send({
2✔
412
            name: existingDestObject.name,
413
            bucketId: existingDestObject.bucket_id ?? destinationBucket,
2!
414
            tenant: this.db.tenant(),
415
            version: existingDestObject.version,
416
            reqId: this.db.reqId,
417
            sbReqId: this.db.sbReqId,
418
          })
419
        }
420

421
        return destinationObject
14✔
422
      })
423

424
      await ObjectCreatedCopyEvent.sendWebhook({
14✔
425
        tenant: this.db.tenant(),
426
        name: destinationKey,
427
        version: newVersion,
428
        bucketId: destinationBucket,
429
        metadata,
430
        uploadType,
431
        reqId: this.db.reqId,
432
        sbReqId: this.db.sbReqId,
433
      })
434

435
      return {
14✔
436
        destObject: destinationObject,
437
        httpStatusCode: copyResult.httpStatusCode,
438
        eTag: copyResult.eTag,
439
        lastModified: copyResult.lastModified,
440
      }
441
    } catch (e) {
442
      await ObjectAdminDelete.send({
×
443
        name: destinationKey,
444
        bucketId: destinationBucket,
445
        tenant: this.db.tenant(),
446
        version: newVersion,
447
        reqId: this.db.reqId,
448
        sbReqId: this.db.sbReqId,
449
      })
450
      throw e
×
451
    }
452
  }
453

454
  /**
455
   * Moves an existing remote object to a given location
456
   * @param sourceObjectName
457
   * @param destinationBucket
458
   * @param destinationObjectName
459
   * @param owner
460
   */
461
  async moveObject(
462
    sourceObjectName: string,
463
    destinationBucket: string,
464
    destinationObjectName: string,
465
    uploadType: 'standard' | 's3' | 'resumable',
466
    owner?: string
467
  ) {
468
    mustBeValidKey(destinationObjectName)
13✔
469

470
    const newVersion = randomUUID()
13✔
471
    const s3SourceKey = this.location.getKeyLocation({
13✔
472
      tenantId: this.db.tenantId,
473
      bucketId: this.bucketId,
474
      objectName: sourceObjectName,
475
    })
476

477
    const s3DestinationKey = this.location.getKeyLocation({
13✔
478
      tenantId: this.db.tenantId,
479
      bucketId: destinationBucket,
480
      objectName: destinationObjectName,
481
    })
482

483
    await this.db.testPermission((db) => {
13✔
484
      return Promise.all([
13✔
485
        db.findObject(this.bucketId, sourceObjectName, 'id'),
486
        db.updateObject(this.bucketId, sourceObjectName, {
487
          name: destinationObjectName,
488
          version: newVersion,
489
          bucket_id: destinationBucket,
490
          owner,
491
        }),
492
      ])
493
    })
494

495
    const sourceObj = await this.db
6✔
496
      .asSuperUser()
497
      .findObject(this.bucketId, sourceObjectName, 'id, version,user_metadata')
498

499
    if (s3SourceKey === s3DestinationKey) {
6!
500
      return {
×
501
        destObject: sourceObj,
502
      }
503
    }
504

505
    try {
6✔
506
      await this.backend.copyObject(
6✔
507
        this.location.getRootLocation(),
508
        s3SourceKey,
509
        sourceObj.version,
510
        s3DestinationKey,
511
        newVersion
512
      )
513

514
      const metadata = await this.backend.headObject(
6✔
515
        this.location.getRootLocation(),
516
        s3DestinationKey,
517
        newVersion
518
      )
519

520
      return this.db.asSuperUser().withTransaction(async (db) => {
5✔
521
        await db.waitObjectLock(this.bucketId, destinationObjectName, undefined, {
5✔
522
          timeout: 5000,
523
        })
524

525
        const sourceObject = await db.findObject(
5✔
526
          this.bucketId,
527
          sourceObjectName,
528
          'id,version,metadata,user_metadata',
529
          {
530
            forUpdate: true,
531
            dontErrorOnEmpty: false,
532
          }
533
        )
534

535
        await db.updateObject(this.bucketId, sourceObjectName, {
5✔
536
          name: destinationObjectName,
537
          bucket_id: destinationBucket,
538
          version: newVersion,
539
          owner,
540
          metadata,
541
          user_metadata: sourceObj.user_metadata,
542
        })
543

544
        await ObjectAdminDelete.send({
5✔
545
          name: sourceObjectName,
546
          bucketId: this.bucketId,
547
          tenant: this.db.tenant(),
548
          version: sourceObj.version,
549
          reqId: this.db.reqId,
550
          sbReqId: this.db.sbReqId,
551
        })
552

553
        await Promise.allSettled([
5✔
554
          ObjectRemovedMove.sendWebhook({
555
            tenant: this.db.tenant(),
556
            name: sourceObjectName,
557
            bucketId: this.bucketId,
558
            reqId: this.db.reqId,
559
            sbReqId: this.db.sbReqId,
560
            version: sourceObject.version,
561
            metadata: sourceObject.metadata,
562
          }),
563
          ObjectCreatedMove.sendWebhook({
564
            tenant: this.db.tenant(),
565
            name: destinationObjectName,
566
            version: newVersion,
567
            bucketId: destinationBucket,
568
            metadata,
569
            uploadType,
570
            oldObject: {
571
              name: sourceObjectName,
572
              bucketId: this.bucketId,
573
              reqId: this.db.reqId,
574
              version: sourceObject.version,
575
            },
576
            reqId: this.db.reqId,
577
            sbReqId: this.db.sbReqId,
578
          }),
579
        ])
580

581
        return {
5✔
582
          destObject: {
583
            id: sourceObject.id,
584
            name: destinationObjectName,
585
            bucket_id: destinationBucket,
586
            version: newVersion,
587
            owner,
588
            metadata,
589
          },
590
        }
591
      })
592
    } catch (e) {
593
      await ObjectAdminDelete.send({
1✔
594
        name: destinationObjectName,
595
        bucketId: destinationBucket,
596
        tenant: this.db.tenant(),
597
        version: newVersion,
598
        reqId: this.db.reqId,
599
        sbReqId: this.db.sbReqId,
600
      })
601
      throw e
1✔
602
    }
603
  }
604

605
  /**
606
   * Search objects by prefix
607
   * @param prefix
608
   * @param options
609
   */
610
  async searchObjects(prefix: string, options: SearchObjectOption) {
611
    if (prefix.length > 0 && !prefix.endsWith('/')) {
14✔
612
      // assuming prefix is always a folder
613
      prefix = `${prefix}/`
3✔
614
    }
615

616
    return this.db.searchObjects(this.bucketId, prefix, options)
14✔
617
  }
618

619
  async listObjectsV2(options?: {
620
    prefix?: string
621
    delimiter?: string
622
    cursor?: string
623
    startAfter?: string
624
    maxKeys?: number
625
    encodingType?: 'url'
626
    sortBy?: {
627
      column: 'name' | 'created_at' | 'updated_at'
628
      order?: string
629
    }
630
  }): Promise<ListObjectsV2Result> {
631
    const limit = Math.min(options?.maxKeys || 1000, 1000)
210!
632
    const prefix = options?.prefix || ''
210✔
633
    const delimiter = options?.delimiter
210✔
634

635
    const cursor = options?.cursor ? decodeContinuationToken(options.cursor) : undefined
210✔
636
    let searchResult = await this.db.listObjectsV2(this.bucketId, {
210✔
637
      prefix: options?.prefix,
638
      delimiter: options?.delimiter,
639
      maxKeys: limit + 1,
640
      nextToken: cursor?.startAfter,
641
      startAfter: cursor?.startAfter || options?.startAfter,
258✔
642
      sortBy: {
643
        order: cursor?.sortOrder || options?.sortBy?.order,
258✔
644
        column: cursor?.sortColumn || options?.sortBy?.column,
295✔
645
        after: cursor?.sortColumnAfter,
646
      },
647
    })
648

649
    let prevPrefix = ''
210✔
650

651
    if (delimiter) {
210✔
652
      const delimitedResults: Obj[] = []
86✔
653
      for (const object of searchResult) {
86✔
654
        let idx = object.name.replace(prefix, '').indexOf(delimiter)
411✔
655

656
        if (idx >= 0) {
411!
657
          idx = prefix.length + idx + delimiter.length
×
658
          const currPrefix = object.name.substring(0, idx)
×
659
          if (currPrefix === prevPrefix) {
×
660
            continue
×
661
          }
662
          prevPrefix = currPrefix
×
663
          delimitedResults.push({
×
664
            id: null,
665
            name: currPrefix,
666
            bucket_id: object.bucket_id,
667
          })
668
          continue
×
669
        }
670

671
        delimitedResults.push(object)
411✔
672
      }
673
      searchResult = delimitedResults
86✔
674
    }
675

676
    let isTruncated = false
210✔
677

678
    if (searchResult.length > limit) {
210✔
679
      searchResult = searchResult.slice(0, limit)
165✔
680
      isTruncated = true
165✔
681
    }
682

683
    const folders: Obj[] = []
210✔
684
    const objects: Obj[] = []
210✔
685
    searchResult.forEach((obj) => {
210✔
686
      const target = obj.id === null ? folders : objects
895✔
687
      const name = obj.id === null && !obj.name.endsWith('/') ? obj.name + '/' : obj.name
895✔
688
      target.push({
895✔
689
        ...obj,
690
        name: options?.encodingType === 'url' ? encodeURIComponent(name) : name,
895✔
691
      })
692
    })
693

694
    let nextContinuationToken: string | undefined
695
    let nextCursorKey: string | undefined
696

697
    if (isTruncated) {
210✔
698
      const sortColumn = (cursor?.sortColumn || options?.sortBy?.column) as
165✔
699
        | 'name'
700
        | 'created_at'
701
        | 'updated_at'
702
        | undefined
703

704
      nextContinuationToken = encodeContinuationToken({
165✔
705
        startAfter: searchResult[searchResult.length - 1].name,
706
        sortOrder: cursor?.sortOrder || options?.sortBy?.order,
193✔
707
        sortColumn,
708
        sortColumnAfter:
709
          sortColumn && sortColumn !== 'name' && searchResult[searchResult.length - 1][sortColumn]
555✔
710
            ? new Date(searchResult[searchResult.length - 1][sortColumn] || '').toISOString()
100!
711
            : undefined,
712
      })
713
      nextCursorKey = searchResult[searchResult.length - 1].name
165✔
714
    }
715

716
    return {
210✔
717
      hasNext: isTruncated,
718
      nextCursor: nextContinuationToken,
719
      nextCursorKey,
720
      folders,
721
      objects,
722
    }
723
  }
724

725
  /**
726
   * Generates a signed url for accessing an object securely
727
   * @param objectName
728
   * @param url
729
   * @param expiresIn seconds
730
   * @param metadata
731
   */
732
  async signObjectUrl(
733
    objectName: string,
734
    url: string,
735
    expiresIn: number,
736
    metadata?: Record<string, string | object | undefined>
737
  ) {
738
    await this.findObject(objectName)
8✔
739

740
    metadata = Object.keys(metadata || {}).reduce((all, key) => {
5!
741
      if (!all[key]) {
13✔
742
        delete all[key]
6✔
743
      }
744
      return all
13✔
745
    }, metadata || {})
8!
746

747
    // security-in-depth: as signObjectUrl could be used as a signing oracle,
748
    // make sure it's never able to specify a role JWT claim, nor the claims that
749
    // identify an upload token (upsert/owner) — otherwise a download token could
750
    // be crafted to satisfy the upload-endpoint's legacy compatibility check.
751
    delete metadata['role']
8✔
752
    delete metadata['upsert']
8✔
753
    delete metadata['owner']
8✔
754

755
    const urlParts = url.split('/')
8✔
756
    const urlToSign = decodeURI(urlParts.splice(3).join('/'))
8✔
757
    const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
8✔
758
    // `url` and `scope` are spread last so attacker-controlled metadata can never
759
    // override the intended object path or the token scope (token-forgery defense).
760
    const token = await signJWT(
5✔
761
      { ...metadata, url: urlToSign, scope: SIGNED_URL_SCOPE_DOWNLOAD },
762
      urlSigningKey,
763
      expiresIn
764
    )
765

766
    let urlPath = 'object'
5✔
767

768
    if (metadata?.transformations) {
5✔
769
      urlPath = 'render/image'
2✔
770
    }
771

772
    // @todo parse the url properly
773
    return `/${urlPath}/sign/${urlToSign}?token=${token}`
5✔
774
  }
775

776
  /**
777
   * Generates multiple signed urls
778
   * @param paths
779
   * @param expiresIn
780
   */
781
  async signObjectUrls(paths: string[], expiresIn: number) {
782
    let results: { name: string }[] = []
4✔
783

784
    for (let i = 0; i < paths.length; ) {
4✔
785
      const pathsSubset = []
118✔
786
      let urlParamLength = 0
118✔
787

788
      for (; i < paths.length && urlParamLength < requestUrlLengthLimit; i++) {
118✔
789
        const path = paths[i]
30,004✔
790
        pathsSubset.push(path)
30,004✔
791
        urlParamLength += encodeURIComponent(path).length + 9 // length of '%22%2C%22'
30,004✔
792
      }
793

794
      const objects = await this.findObjects(pathsSubset, 'name')
118✔
795
      results = results.concat(objects)
118✔
796
    }
797

798
    const nameSet = new Set(results.map(({ name }) => name))
4✔
799

800
    const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
4✔
801

802
    return Promise.all(
4✔
803
      paths.map(async (path) => {
804
        let error = null
30,004✔
805
        let signedURL = null
30,004✔
806
        if (nameSet.has(path)) {
30,004!
807
          const urlToSign = `${this.bucketId}/${path}`
×
NEW
808
          const token = await signJWT(
×
809
            { url: urlToSign, scope: SIGNED_URL_SCOPE_DOWNLOAD },
810
            urlSigningKey,
811
            expiresIn
812
          )
UNCOV
813
          signedURL = `/object/sign/${urlToSign}?token=${token}`
×
814
        } else {
815
          error = 'Either the object does not exist or you do not have access to it'
30,004✔
816
        }
817
        return {
30,004✔
818
          error,
819
          path,
820
          signedURL,
821
        }
822
      })
823
    )
824
  }
825

826
  /**
827
   * Generates a signed url for uploading an object
828
   * @param objectName
829
   * @param url
830
   * @param expiresIn seconds
831
   * @param owner
832
   * @param options
833
   */
834
  async signUploadObjectUrl(
835
    objectName: string,
836
    url: string,
837
    expiresIn: number,
838
    owner?: string,
839
    options?: {
840
      upsert?: boolean
841
      userMetadata?: Record<string, unknown>
842
      metadata?: CanUploadMetadata
843
    }
844
  ) {
845
    // check if user has INSERT permissions
846
    await this.uploader.canUpload({
14✔
847
      bucketId: this.bucketId,
848
      objectName,
849
      owner,
850
      isUpsert: options?.upsert ?? false,
20✔
851
      userMetadata: options?.userMetadata,
852
      metadata: options?.metadata,
853
    })
854

855
    const { urlSigningKey } = await getJwtSecret(this.db.tenantId)
10✔
856
    const token = await signJWT(
10✔
857
      { owner, url, upsert: Boolean(options?.upsert), scope: SIGNED_URL_SCOPE_UPLOAD },
858
      urlSigningKey,
859
      expiresIn
860
    )
861

862
    return { url: `/object/upload/sign/${url}?token=${token}`, token }
10✔
863
  }
864

865
  /**
866
   * Verify a signed-URL token for a specific object, enforcing that it was issued
867
   * for the requested action. This is the single place that validates a signed
868
   * token: signature, scope, object-path binding, and expiry.
869
   * @param token
870
   * @param objectName
871
   * @param scope the action the token must be authorized for (download or upload)
872
   */
873
  async verifyObjectSignature<Scope extends SignedUrlScope>(
874
    token: string,
875
    objectName: string,
876
    scope: Scope
877
  ): Promise<Scope extends typeof SIGNED_URL_SCOPE_UPLOAD ? SignedUploadToken : SignedToken> {
878
    const { secret: jwtSecret, jwks } = await getJwtSecret(this.db.tenantId)
35✔
879

880
    let payload: SignedToken | SignedUploadToken
881
    try {
35✔
882
      payload = await verifyJWT<SignedToken | SignedUploadToken>(token, jwtSecret, jwks)
35✔
883
    } catch (e) {
884
      const err = e as Error
10✔
885
      throw ERRORS.InvalidJWT(err)
10✔
886
    }
887

888
    const hasValidScope =
889
      scope === SIGNED_URL_SCOPE_UPLOAD
25✔
890
        ? isUploadScopedToken(payload)
891
        : isDownloadScopedToken(payload)
892
    if (!hasValidScope) {
35✔
893
      throw ERRORS.InvalidSignature(`Token is not scoped for ${scope}`)
5✔
894
    }
895

896
    if (payload.url !== `${this.bucketId}/${objectName}`) {
20✔
897
      throw ERRORS.InvalidSignature()
2✔
898
    }
899

900
    if (payload.exp * 1000 < Date.now()) {
18!
901
      throw ERRORS.ExpiredSignature()
×
902
    }
903

904
    // the scope check above guarantees the payload matches the requested scope;
905
    // TS can't correlate the runtime value with the conditional return type.
906
    return payload as Scope extends typeof SIGNED_URL_SCOPE_UPLOAD ? SignedUploadToken : SignedToken
18✔
907
  }
908
}
909

910
interface ContinuationToken {
911
  startAfter: string
912
  sortOrder?: string // 'asc' | 'desc'
913
  sortColumn?: string
914
  sortColumnAfter?: string
915
}
916

917
const CONTINUATION_TOKEN_PART_MAP: Record<string, keyof ContinuationToken> = {
47✔
918
  l: 'startAfter',
919
  o: 'sortOrder',
920
  c: 'sortColumn',
921
  a: 'sortColumnAfter',
922
}
923

924
function encodeContinuationToken(tokenInfo: ContinuationToken) {
925
  let result = ''
165✔
926
  for (const [k, v] of Object.entries(CONTINUATION_TOKEN_PART_MAP)) {
165✔
927
    if (tokenInfo[v]) {
660✔
928
      result += `${k}:${tokenInfo[v]}\n`
542✔
929
    }
930
  }
931
  return Buffer.from(result.slice(0, -1)).toString('base64')
165✔
932
}
933

934
function decodeContinuationToken(token: string): ContinuationToken {
935
  const decodedParts = Buffer.from(token, 'base64').toString().split('\n')
162✔
936
  const result: ContinuationToken = {
162✔
937
    startAfter: '',
938
    sortOrder: 'asc',
939
  }
940
  for (const part of decodedParts) {
162✔
941
    const partMatch = part.match(/^(\S):(.*)/)
539✔
942
    if (!partMatch || partMatch.length !== 3 || !(partMatch[1] in CONTINUATION_TOKEN_PART_MAP)) {
539!
943
      throw ERRORS.InvalidParameter('continuation token')
×
944
    }
945
    result[CONTINUATION_TOKEN_PART_MAP[partMatch[1]]] = partMatch[2]
539✔
946
  }
947
  return result
162✔
948
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc