Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
8a4ec22
fix: allow Unicode object names
mlatief Dec 30, 2024
4b7f534
test: add unicode key and xml entity edge-case coverage
ferhatelmas Feb 25, 2026
4b95b1b
fix: more tests and edge case handling
ferhatelmas Mar 5, 2026
c36b32f
fix: make migration idempotent
ferhatelmas Mar 5, 2026
0cfa647
fix: more tests for gaps
ferhatelmas Mar 5, 2026
67636fc
fix: oriole compat
ferhatelmas Mar 5, 2026
42639c3
fix: lint
ferhatelmas Mar 5, 2026
7a0d66e
fix: add delete many test
ferhatelmas Mar 5, 2026
fed0dd7
fix: decode logic in xml
ferhatelmas Mar 5, 2026
c5484c2
fix: make delete many example more complex
ferhatelmas Mar 5, 2026
0849e86
fix: more tests for gaps
ferhatelmas Mar 5, 2026
19a6b38
fix: invalid key surrogates
ferhatelmas Mar 5, 2026
0b61d36
fix: sign and more coverage
ferhatelmas Mar 5, 2026
a7e728c
fix: more batch sign coverage
ferhatelmas Mar 5, 2026
56e72ee
fix: cleanup data after run
ferhatelmas Mar 5, 2026
a66ef7c
fix: add tests for webhooks
ferhatelmas Mar 5, 2026
7a0862b
fix: drop dead param for sign
ferhatelmas Mar 5, 2026
df1b661
fix: s3 copy source
ferhatelmas Mar 5, 2026
835d8ab
fix: backward compat for list continuation
ferhatelmas Mar 5, 2026
e72d008
fix: backward compat for s3 continuation
ferhatelmas Mar 5, 2026
609e871
fix: control chars in migration
ferhatelmas Mar 6, 2026
57b8e72
fix: explicit decoding
ferhatelmas Mar 6, 2026
0209a8f
fix: more test coverage
ferhatelmas Mar 6, 2026
4a563c7
fix: post rebase
ferhatelmas Mar 6, 2026
9628e54
fix: test data dependence
ferhatelmas Mar 6, 2026
c6a0740
fix: error shape
ferhatelmas Mar 6, 2026
ce03d4a
fix: inflight pagination
ferhatelmas Mar 9, 2026
3f4b711
fix: backup compat
ferhatelmas Mar 9, 2026
545465c
fix: sign upload canonical raw validation
ferhatelmas Mar 9, 2026
ccd1c28
fix: list url encoding type prefixes
ferhatelmas Mar 9, 2026
a86d443
fix: more cover for list v1 pagination
ferhatelmas Mar 9, 2026
f110ad9
fix: strict path verification for sign
ferhatelmas Mar 9, 2026
a72f6fa
fix: encoder dedupe
ferhatelmas Mar 9, 2026
8135290
fix: more gaps
ferhatelmas Mar 9, 2026
3b468c6
fix: extend migration for multipart tables
ferhatelmas Mar 9, 2026
84931e7
fix: improve migration locking and tests
ferhatelmas Mar 9, 2026
bd80e2c
fix: name check constraint as 400
ferhatelmas Mar 10, 2026
136aff1
fix: copy source with query param
ferhatelmas Mar 10, 2026
2564e3f
fix: path traversal with relaxed key in file backend
ferhatelmas Mar 10, 2026
3d56e91
fix: address review comments
ferhatelmas Mar 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 143 additions & 0 deletions migrations/tenant/57-unicode-object-names.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
DO $$
DECLARE
-- SQL_ASCII databases do not have reliable Unicode code point semantics, so
-- the migration needs a byte-pattern branch instead of U&'' character checks.
-- This is the case for OrioleDB and/or --locale=C databases.
server_encoding text := current_setting('server_encoding');
BEGIN
IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 'objects_name_check'
AND n.nspname = 'storage'
AND t.relname = 'objects'
) THEN
IF server_encoding = 'SQL_ASCII' THEN
EXECUTE $ddl$
ALTER TABLE "storage"."objects"
ADD CONSTRAINT objects_name_check
CHECK (
name !~ E'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'
AND name !~ E'\\xED[\\xA0-\\xBF][\\x80-\\xBF]'
AND name !~ E'\\xEF\\xBF\\xBE|\\xEF\\xBF\\xBF'
) NOT VALID
$ddl$;
ELSE
EXECUTE $ddl$
ALTER TABLE "storage"."objects"
ADD CONSTRAINT objects_name_check
CHECK (
name !~ E'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'
AND POSITION(U&'\FFFE' IN name) = 0
AND POSITION(U&'\FFFF' IN name) = 0
) NOT VALID
$ddl$;
END IF;
END IF;

IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 'objects_name_check'
AND n.nspname = 'storage'
AND t.relname = 'objects'
AND c.convalidated = false
) THEN
EXECUTE 'ALTER TABLE "storage"."objects" VALIDATE CONSTRAINT objects_name_check';
END IF;

IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 's3_multipart_uploads_key_check'
AND n.nspname = 'storage'
AND t.relname = 's3_multipart_uploads'
) THEN
IF server_encoding = 'SQL_ASCII' THEN
EXECUTE $ddl$
ALTER TABLE "storage"."s3_multipart_uploads"
ADD CONSTRAINT s3_multipart_uploads_key_check
CHECK (
key !~ E'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'
AND key !~ E'\\xED[\\xA0-\\xBF][\\x80-\\xBF]'
AND key !~ E'\\xEF\\xBF\\xBE|\\xEF\\xBF\\xBF'
) NOT VALID
$ddl$;
ELSE
EXECUTE $ddl$
ALTER TABLE "storage"."s3_multipart_uploads"
ADD CONSTRAINT s3_multipart_uploads_key_check
CHECK (
key !~ E'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'
AND POSITION(U&'\FFFE' IN key) = 0
AND POSITION(U&'\FFFF' IN key) = 0
) NOT VALID
$ddl$;
END IF;
END IF;

IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 's3_multipart_uploads_key_check'
AND n.nspname = 'storage'
AND t.relname = 's3_multipart_uploads'
AND c.convalidated = false
) THEN
EXECUTE 'ALTER TABLE "storage"."s3_multipart_uploads" VALIDATE CONSTRAINT s3_multipart_uploads_key_check';
END IF;

IF NOT EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 's3_multipart_uploads_parts_key_check'
AND n.nspname = 'storage'
AND t.relname = 's3_multipart_uploads_parts'
) THEN
IF server_encoding = 'SQL_ASCII' THEN
EXECUTE $ddl$
ALTER TABLE "storage"."s3_multipart_uploads_parts"
ADD CONSTRAINT s3_multipart_uploads_parts_key_check
CHECK (
key !~ E'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'
AND key !~ E'\\xED[\\xA0-\\xBF][\\x80-\\xBF]'
AND key !~ E'\\xEF\\xBF\\xBE|\\xEF\\xBF\\xBF'
) NOT VALID
$ddl$;
ELSE
EXECUTE $ddl$
ALTER TABLE "storage"."s3_multipart_uploads_parts"
ADD CONSTRAINT s3_multipart_uploads_parts_key_check
CHECK (
key !~ E'[\\x00-\\x08\\x0B\\x0C\\x0E-\\x1F]'
AND POSITION(U&'\FFFE' IN key) = 0
AND POSITION(U&'\FFFF' IN key) = 0
) NOT VALID
$ddl$;
END IF;
END IF;

IF EXISTS (
SELECT 1
FROM pg_constraint c
JOIN pg_class t ON t.oid = c.conrelid
JOIN pg_namespace n ON n.oid = t.relnamespace
WHERE c.conname = 's3_multipart_uploads_parts_key_check'
AND n.nspname = 'storage'
AND t.relname = 's3_multipart_uploads_parts'
AND c.convalidated = false
) THEN
EXECUTE 'ALTER TABLE "storage"."s3_multipart_uploads_parts" VALIDATE CONSTRAINT s3_multipart_uploads_parts_key_check';
END IF;
END
$$;
45 changes: 44 additions & 1 deletion src/http/plugins/xml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,38 @@ import xml from 'xml2js'
type XmlParserOptions = { disableContentParser?: boolean; parseAsArray?: string[] }
type RequestError = Error & { statusCode?: number }

function isValidXmlCodePoint(codePoint: number): boolean {
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
return false
}

return (
codePoint === 0x9 ||
codePoint === 0xa ||
codePoint === 0xd ||
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
(codePoint >= 0x10000 && codePoint <= 0x10ffff)
)
}

function getInvalidXmlNumericEntity(value: string): string | undefined {
const numericEntityPattern = /&#([xX][0-9a-fA-F]{1,6}|[0-9]{1,7});/g

let match = numericEntityPattern.exec(value)
while (match) {
const rawValue = match[1]
const isHex = rawValue[0].toLowerCase() === 'x'
const codePoint = Number.parseInt(isHex ? rawValue.slice(1) : rawValue, isHex ? 16 : 10)

if (!isValidXmlCodePoint(codePoint)) {
return match[0]
}

match = numericEntityPattern.exec(value)
}
}

function forcePathAsArray(node: unknown, pathSegments: string[]): void {
if (pathSegments.length === 0 || node === null || node === undefined) {
return
Expand Down Expand Up @@ -52,8 +84,19 @@ export const xmlParser = fastifyPlugin(
return
}

const xmlBody = typeof body === 'string' ? body : body.toString('utf8')
const invalidNumericEntity = getInvalidXmlNumericEntity(xmlBody)
if (invalidNumericEntity) {
const parseError: RequestError = new Error(
`Invalid XML payload: invalid numeric entity ${invalidNumericEntity}`
)
parseError.statusCode = 400
done(parseError)
return
}

xml.parseString(
body,
xmlBody,
{
explicitArray: false,
trim: true,
Expand Down
4 changes: 2 additions & 2 deletions src/http/routes/object/getSignedObject.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { getConfig } from '../../../config'
import { SignedToken, verifyJWT } from '../../../internal/auth'
import { getJwtSecret } from '../../../internal/database'
import { ERRORS } from '../../../internal/errors'
import { doesSignedTokenMatchRequestPath } from '../../../internal/http'
import { ROUTE_OPERATIONS } from '../operations'

const { storageS3Bucket } = getConfig()
Expand Down Expand Up @@ -71,9 +72,8 @@ export default async function routes(fastify: FastifyInstance) {
}

const { url, exp } = payload
const path = `${request.params.bucketName}/${request.params['*']}`

if (url !== path) {
if (!doesSignedTokenMatchRequestPath(request.raw.url, '/object/sign', url)) {
throw ERRORS.InvalidSignature()
}

Expand Down
3 changes: 1 addition & 2 deletions src/http/routes/object/getSignedURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,6 @@ export default async function routes(fastify: FastifyInstance) {
const objectName = request.params['*']
const { expiresIn } = request.body

const urlPath = request.url.split('?').shift()
const imageTransformationEnabled = await isImageTransformationEnabled(request.tenantId)

const transformationOptions = imageTransformationEnabled
Expand All @@ -82,7 +81,7 @@ export default async function routes(fastify: FastifyInstance) {

const signedURL = await request.storage
.from(bucketName)
.signObjectUrl(objectName, urlPath as string, expiresIn, transformationOptions)
.signObjectUrl(objectName, expiresIn, transformationOptions)

return response.status(200).send({ signedURL })
}
Expand Down
4 changes: 1 addition & 3 deletions src/http/routes/object/getSignedUploadURL.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,11 +67,9 @@ export default async function routes(fastify: FastifyInstance) {
const objectName = request.params['*']
const owner = request.owner

const urlPath = `${bucketName}/${objectName}`

const signedUpload = await request.storage
.from(bucketName)
.signUploadObjectUrl(objectName, urlPath as string, uploadSignedUrlExpirationTime, owner, {
.signUploadObjectUrl(objectName, uploadSignedUrlExpirationTime, owner, {
upsert: request.headers['x-upsert'] === 'true',
})

Expand Down
22 changes: 19 additions & 3 deletions src/http/routes/object/uploadSignedObject.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import fastifyMultipart from '@fastify/multipart'
import { SignedUploadToken, verifyJWT } from '@internal/auth'
import { getJwtSecret } from '@internal/database'
import { ERRORS } from '@internal/errors'
import { doesSignedTokenMatchRequestPath } from '@internal/http'
import { FastifyInstance } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
import { ROUTE_OPERATIONS } from '../operations'
Expand Down Expand Up @@ -83,9 +87,21 @@ export default async function routes(fastify: FastifyInstance) {
const { bucketName } = request.params
const objectName = request.params['*']

const { owner, upsert } = await request.storage
.from(bucketName)
.verifyObjectSignature(token, objectName)
let payload: SignedUploadToken
const { secret: jwtSecret, jwks } = await getJwtSecret(request.tenantId)

try {
payload = (await verifyJWT(token, jwtSecret, jwks)) as SignedUploadToken
} catch (e) {
const err = e as Error
throw ERRORS.InvalidJWT(err)
}

const { owner, upsert, url } = payload

if (!doesSignedTokenMatchRequestPath(request.raw.url, '/object/upload/sign', url)) {
throw ERRORS.InvalidSignature()
}

const { objectMetadata, path } = await request.storage
.asSuperUser()
Expand Down
6 changes: 2 additions & 4 deletions src/http/routes/render/renderSignedImage.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { SignedToken, verifyJWT } from '@internal/auth'
import { getJwtSecret, getTenantConfig } from '@internal/database'
import { ERRORS } from '@internal/errors'
import { doesSignedTokenMatchRequestPath } from '@internal/http'
import { ImageRenderer } from '@storage/renderer'
import { FastifyInstance } from 'fastify'
import { FromSchema } from 'json-schema-to-ts'
Expand Down Expand Up @@ -68,10 +69,7 @@ export default async function routes(fastify: FastifyInstance) {
}

const { url, transformations, exp } = payload

const path = `${request.params.bucketName}/${request.params['*']}`

if (url !== path) {
if (!doesSignedTokenMatchRequestPath(request.raw.url, '/render/image/sign', url)) {
throw ERRORS.InvalidSignature()
}

Expand Down
3 changes: 2 additions & 1 deletion src/internal/database/migrations/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,5 @@ export const DBMigration = {
'drop-index-object-level': 54,
'prevent-direct-deletes': 55,
'fix-optimized-search-function': 56,
}
'unicode-object-names': 57,
} as const
11 changes: 10 additions & 1 deletion src/internal/errors/codes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { safeEncodeURIComponent } from '../http'
import { StorageBackendError } from './storage-error'

export enum ErrorCode {
Expand Down Expand Up @@ -319,12 +320,20 @@ export const ERRORS = {
originalError: e,
}),

InvalidObjectName: (e?: Error) =>
new StorageBackendError({
code: ErrorCode.InvalidKey,
httpStatusCode: 400,
message: 'Invalid object name',
originalError: e,
}),

InvalidKey: (key: string, e?: Error) =>
new StorageBackendError({
code: ErrorCode.InvalidKey,
resource: key,
httpStatusCode: 400,
message: `Invalid key: ${key}`,
message: `Invalid key: ${safeEncodeURIComponent(key)}`,
originalError: e,
}),

Expand Down
1 change: 1 addition & 0 deletions src/internal/http/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './agent'
export * from './url'
Loading