Skip to content

Commit 3d56e91

Browse files
committed
fix: address review comments
Signed-off-by: ferhat elmas <elmas.ferhat@gmail.com>
1 parent 2564e3f commit 3d56e91

18 files changed

Lines changed: 237 additions & 171 deletions

File tree

migrations/tenant/57-unicode-object-names.sql

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
DO $$
22
DECLARE
3+
-- SQL_ASCII databases do not have reliable Unicode code point semantics, so
4+
-- the migration needs a byte-pattern branch instead of U&'' character checks.
5+
-- This is the case for OrioleDB and/or --locale=C databases.
36
server_encoding text := current_setting('server_encoding');
47
BEGIN
58
IF NOT EXISTS (

src/http/plugins/xml.ts

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,36 @@ import xml from 'xml2js'
66
type XmlParserOptions = { disableContentParser?: boolean; parseAsArray?: string[] }
77
type RequestError = Error & { statusCode?: number }
88

9-
export function decodeXmlNumericEntities(value: string): string {
10-
const isValidXmlCodePoint = (codePoint: number) => {
11-
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
12-
return false
13-
}
14-
15-
return (
16-
codePoint === 0x9 ||
17-
codePoint === 0xa ||
18-
codePoint === 0xd ||
19-
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
20-
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
21-
(codePoint >= 0x10000 && codePoint <= 0x10ffff)
22-
)
9+
function isValidXmlCodePoint(codePoint: number): boolean {
10+
if (!Number.isInteger(codePoint) || codePoint < 0 || codePoint > 0x10ffff) {
11+
return false
2312
}
2413

25-
return value.replace(
26-
/&#([xX][0-9a-fA-F]{1,6}|[0-9]{1,7});/g,
27-
(match: string, rawValue: string) => {
28-
const isHex = rawValue[0].toLowerCase() === 'x'
29-
const codePoint = Number.parseInt(isHex ? rawValue.slice(1) : rawValue, isHex ? 16 : 10)
30-
if (!isValidXmlCodePoint(codePoint)) {
31-
return match
32-
}
14+
return (
15+
codePoint === 0x9 ||
16+
codePoint === 0xa ||
17+
codePoint === 0xd ||
18+
(codePoint >= 0x20 && codePoint <= 0xd7ff) ||
19+
(codePoint >= 0xe000 && codePoint <= 0xfffd) ||
20+
(codePoint >= 0x10000 && codePoint <= 0x10ffff)
21+
)
22+
}
23+
24+
function getInvalidXmlNumericEntity(value: string): string | undefined {
25+
const numericEntityPattern = /&#([xX][0-9a-fA-F]{1,6}|[0-9]{1,7});/g
26+
27+
let match = numericEntityPattern.exec(value)
28+
while (match) {
29+
const rawValue = match[1]
30+
const isHex = rawValue[0].toLowerCase() === 'x'
31+
const codePoint = Number.parseInt(isHex ? rawValue.slice(1) : rawValue, isHex ? 16 : 10)
3332

34-
return String.fromCodePoint(codePoint)
33+
if (!isValidXmlCodePoint(codePoint)) {
34+
return match[0]
3535
}
36-
)
36+
37+
match = numericEntityPattern.exec(value)
38+
}
3739
}
3840

3941
function forcePathAsArray(node: unknown, pathSegments: string[]): void {
@@ -82,16 +84,23 @@ export const xmlParser = fastifyPlugin(
8284
return
8385
}
8486

87+
const xmlBody = typeof body === 'string' ? body : body.toString('utf8')
88+
const invalidNumericEntity = getInvalidXmlNumericEntity(xmlBody)
89+
if (invalidNumericEntity) {
90+
const parseError: RequestError = new Error(
91+
`Invalid XML payload: invalid numeric entity ${invalidNumericEntity}`
92+
)
93+
parseError.statusCode = 400
94+
done(parseError)
95+
return
96+
}
97+
8598
xml.parseString(
86-
body,
99+
xmlBody,
87100
{
88101
explicitArray: false,
89102
trim: true,
90-
valueProcessors: [
91-
decodeXmlNumericEntities,
92-
xml.processors.parseNumbers,
93-
xml.processors.parseBooleans,
94-
],
103+
valueProcessors: [xml.processors.parseNumbers, xml.processors.parseBooleans],
95104
},
96105
(err: Error | null, parsed: unknown) => {
97106
if (err) {

src/http/routes/object/getSignedObject.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import { getConfig } from '../../../config'
44
import { SignedToken, verifyJWT } from '../../../internal/auth'
55
import { getJwtSecret } from '../../../internal/database'
66
import { ERRORS } from '../../../internal/errors'
7+
import { doesSignedTokenMatchRequestPath } from '../../../internal/http'
78
import { ROUTE_OPERATIONS } from '../operations'
8-
import { doesSignedTokenMatchRequestPath } from '../signed-url'
99

1010
const { storageS3Bucket } = getConfig()
1111

src/http/routes/object/uploadSignedObject.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import fastifyMultipart from '@fastify/multipart'
22
import { SignedUploadToken, verifyJWT } from '@internal/auth'
33
import { getJwtSecret } from '@internal/database'
44
import { ERRORS } from '@internal/errors'
5+
import { doesSignedTokenMatchRequestPath } from '@internal/http'
56
import { FastifyInstance } from 'fastify'
67
import { FromSchema } from 'json-schema-to-ts'
78
import { ROUTE_OPERATIONS } from '../operations'
8-
import { doesSignedTokenMatchRequestPath } from '../signed-url'
99

1010
const uploadSignedObjectParamsSchema = {
1111
type: 'object',
@@ -97,16 +97,12 @@ export default async function routes(fastify: FastifyInstance) {
9797
throw ERRORS.InvalidJWT(err)
9898
}
9999

100-
const { owner, upsert, url, exp } = payload
100+
const { owner, upsert, url } = payload
101101

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

106-
if (exp * 1000 < Date.now()) {
107-
throw ERRORS.ExpiredSignature()
108-
}
109-
110106
const { objectMetadata, path } = await request.storage
111107
.asSuperUser()
112108
.from(bucketName)

src/http/routes/render/renderSignedImage.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import { SignedToken, verifyJWT } from '@internal/auth'
22
import { getJwtSecret, getTenantConfig } from '@internal/database'
33
import { ERRORS } from '@internal/errors'
4+
import { doesSignedTokenMatchRequestPath } from '@internal/http'
45
import { ImageRenderer } from '@storage/renderer'
56
import { FastifyInstance } from 'fastify'
67
import { FromSchema } from 'json-schema-to-ts'
78
import { getConfig } from '../../../config'
89
import { ROUTE_OPERATIONS } from '../operations'
9-
import { doesSignedTokenMatchRequestPath } from '../signed-url'
1010

1111
const { storageS3Bucket, isMultitenant } = getConfig()
1212

src/http/routes/signed-url.ts

Lines changed: 0 additions & 25 deletions
This file was deleted.

src/internal/errors/codes.ts

Lines changed: 1 addition & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,6 @@
1+
import { safeEncodeURIComponent } from '../http'
12
import { StorageBackendError } from './storage-error'
23

3-
function toWellFormedString(value: string): string {
4-
const maybeToWellFormed = (value as unknown as { toWellFormed?: () => string }).toWellFormed
5-
if (typeof maybeToWellFormed === 'function') {
6-
return maybeToWellFormed.call(value)
7-
}
8-
9-
let normalized = ''
10-
for (let i = 0; i < value.length; i++) {
11-
const currentCodeUnit = value.charCodeAt(i)
12-
13-
if (currentCodeUnit >= 0xd800 && currentCodeUnit <= 0xdbff) {
14-
const nextCodeUnit = value.charCodeAt(i + 1)
15-
if (i + 1 < value.length && nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) {
16-
normalized += value[i] + value[i + 1]
17-
i += 1
18-
} else {
19-
normalized += '\uFFFD'
20-
}
21-
continue
22-
}
23-
24-
if (currentCodeUnit >= 0xdc00 && currentCodeUnit <= 0xdfff) {
25-
normalized += '\uFFFD'
26-
continue
27-
}
28-
29-
normalized += value[i]
30-
}
31-
32-
return normalized
33-
}
34-
35-
function safeEncodeURIComponent(value: string): string {
36-
try {
37-
return encodeURIComponent(value)
38-
} catch {
39-
return encodeURIComponent(toWellFormedString(value))
40-
}
41-
}
42-
434
export enum ErrorCode {
445
NoSuchBucket = 'NoSuchBucket',
456
NoSuchKey = 'NoSuchKey',

src/internal/http/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './agent'
2+
export * from './url'

src/internal/http/url.ts

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
function toWellFormedString(value: string): string {
2+
const maybeToWellFormed = (value as unknown as { toWellFormed?: () => string }).toWellFormed
3+
if (typeof maybeToWellFormed === 'function') {
4+
return maybeToWellFormed.call(value)
5+
}
6+
7+
let normalized = ''
8+
for (let i = 0; i < value.length; i++) {
9+
const currentCodeUnit = value.charCodeAt(i)
10+
11+
if (currentCodeUnit >= 0xd800 && currentCodeUnit <= 0xdbff) {
12+
const nextCodeUnit = value.charCodeAt(i + 1)
13+
if (i + 1 < value.length && nextCodeUnit >= 0xdc00 && nextCodeUnit <= 0xdfff) {
14+
normalized += value[i] + value[i + 1]
15+
i += 1
16+
} else {
17+
normalized += '\uFFFD'
18+
}
19+
continue
20+
}
21+
22+
if (currentCodeUnit >= 0xdc00 && currentCodeUnit <= 0xdfff) {
23+
normalized += '\uFFFD'
24+
continue
25+
}
26+
27+
normalized += value[i]
28+
}
29+
30+
return normalized
31+
}
32+
33+
export function safeEncodeURIComponent(value: string): string {
34+
try {
35+
return encodeURIComponent(value)
36+
} catch {
37+
return encodeURIComponent(toWellFormedString(value))
38+
}
39+
}
40+
41+
export function encodePathPreservingSeparators(path: string): string {
42+
return path
43+
.split('/')
44+
.map((pathToken) => safeEncodeURIComponent(pathToken))
45+
.join('/')
46+
}
47+
48+
export function encodeBucketAndObjectPath(bucket: string, key: string): string {
49+
return `${safeEncodeURIComponent(bucket)}/${encodePathPreservingSeparators(key)}`
50+
}
51+
52+
function stripQueryString(rawUrl: string): string {
53+
const queryIdx = rawUrl.indexOf('?')
54+
return queryIdx === -1 ? rawUrl : rawUrl.slice(0, queryIdx)
55+
}
56+
57+
export function doesSignedTokenMatchRequestPath(
58+
rawUrl: string | undefined,
59+
routePrefix: string,
60+
signedObjectPath: string
61+
): boolean {
62+
if (!rawUrl) {
63+
return false
64+
}
65+
66+
const pathname = stripQueryString(rawUrl)
67+
const expectedPath = `${routePrefix}/${encodePathPreservingSeparators(signedObjectPath)}`
68+
return pathname === expectedPath
69+
}

src/storage/backend/s3/adapter.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
import { Progress, Upload } from '@aws-sdk/lib-storage'
2020
import { getSignedUrl } from '@aws-sdk/s3-request-presigner'
2121
import { ERRORS, StorageBackendError } from '@internal/errors'
22-
import { createAgent, InstrumentedAgent } from '@internal/http'
22+
import { createAgent, encodeBucketAndObjectPath, InstrumentedAgent } from '@internal/http'
2323
import { monitorStream } from '@internal/streams'
2424
import { NodeHttpHandler } from '@smithy/node-http-handler'
2525
import { BackupObjectInfo, ObjectBackup } from '@storage/backend/s3/backup'
@@ -32,7 +32,6 @@ import {
3232
UploadPart,
3333
withOptionalVersion,
3434
} from './../adapter'
35-
import { encodeCopySource } from './copy-source'
3635

3736
const {
3837
storageS3UploadQueueSize,
@@ -256,7 +255,7 @@ export class S3Backend implements StorageBackendAdapter {
256255
try {
257256
const command = new CopyObjectCommand({
258257
Bucket: bucket,
259-
CopySource: encodeCopySource(bucket, withOptionalVersion(source, version)),
258+
CopySource: encodeBucketAndObjectPath(bucket, withOptionalVersion(source, version)),
260259
Key: withOptionalVersion(destination, destinationVersion),
261260
CopySourceIfMatch: conditions?.ifMatch,
262261
CopySourceIfNoneMatch: conditions?.ifNoneMatch,
@@ -569,7 +568,7 @@ export class S3Backend implements StorageBackendAdapter {
569568
Key: withOptionalVersion(key, version),
570569
UploadId,
571570
PartNumber,
572-
CopySource: encodeCopySource(
571+
CopySource: encodeBucketAndObjectPath(
573572
storageS3Bucket,
574573
withOptionalVersion(sourceKey, sourceKeyVersion)
575574
),

0 commit comments

Comments
 (0)