fix: handle control character object keys (#139)

This commit is contained in:
安正超
2026-06-21 11:26:13 +08:00
committed by GitHub
parent 55079b713e
commit aa47d8aaf6
4 changed files with 136 additions and 1 deletions
+8 -1
View File
@@ -18,6 +18,7 @@ import {
} from "@aws-sdk/client-s3"
import { getSignedUrl } from "@aws-sdk/s3-request-presigner"
import { useS3 } from "@/contexts/s3-context"
import { decodeS3UrlEncodedObjectList, decodeS3UrlEncodedObjectVersions } from "@/lib/s3-object-encoding"
function attachIncludeDeletedHeader(command: ListObjectsV2Command) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -101,11 +102,13 @@ export function useObject(bucket: string) {
MaxKeys: pageSize,
Delimiter: "/",
ContinuationToken: continuationToken,
EncodingType: "url",
})
if (options?.includeDeleted) {
attachIncludeDeletedHeader(command)
}
return client.send(command)
const response = await client.send(command)
return decodeS3UrlEncodedObjectList(response)
},
[client],
)
@@ -121,8 +124,10 @@ export function useObject(bucket: string) {
Bucket: bucketName,
Prefix: prefix,
ContinuationToken: continuationToken,
EncodingType: "url",
}),
)
decodeS3UrlEncodedObjectList(data)
data.Contents?.forEach((item) => {
if (item.Key) callback(item.Key)
@@ -256,8 +261,10 @@ export function useObject(bucket: string) {
Bucket: bucket,
Prefix: key,
Delimiter: "/",
EncodingType: "url",
}),
)
decodeS3UrlEncodedObjectVersions(res)
const Versions = (res.Versions ?? []).filter((v) => v.Key === key)
const DeleteMarkers = (res.DeleteMarkers ?? []).filter((m) => m.Key === key)
Versions.sort((a, b) => new Date(b.LastModified!).getTime() - new Date(a.LastModified!).getTime())
+5
View File
@@ -5,6 +5,7 @@ import {
ListObjectVersionsCommand,
S3Client,
} from "@aws-sdk/client-s3"
import { decodeS3UrlEncodedObjectList, decodeS3UrlEncodedObjectVersions } from "./s3-object-encoding"
import type { ManagedTask, TaskHandler, TaskLifecycleStatus } from "./task-manager"
import { createTaskId } from "./task-id"
@@ -116,9 +117,11 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
Prefix: prefix,
KeyMarker: keyMarker,
VersionIdMarker: versionIdMarker,
EncodingType: "url",
}),
{ abortSignal: abortController.signal },
)
decodeS3UrlEncodedObjectVersions(data)
const objectsToDelete: { Key: string; VersionId?: string }[] = []
data.Versions?.forEach((v) => {
@@ -151,9 +154,11 @@ export function createDeleteTaskHelpers(s3Client: S3Client, config: DeleteTaskCo
Bucket: bucketName,
Prefix: prefix,
ContinuationToken: continuationToken,
EncodingType: "url",
}),
{ abortSignal: abortController.signal },
)
decodeS3UrlEncodedObjectList(data)
const objectsToDelete = (data.Contents ?? []).filter((item) => item.Key).map((item) => ({ Key: item.Key! }))
+73
View File
@@ -0,0 +1,73 @@
type S3KeyedItem = {
Key?: string
}
type S3PrefixedItem = {
Prefix?: string
}
type S3ObjectListResponse = {
EncodingType?: string
Prefix?: string
Delimiter?: string
StartAfter?: string
Contents?: S3KeyedItem[]
CommonPrefixes?: S3PrefixedItem[]
}
type S3ObjectVersionsResponse = {
EncodingType?: string
Prefix?: string
Delimiter?: string
KeyMarker?: string
NextKeyMarker?: string
Versions?: S3KeyedItem[]
DeleteMarkers?: S3KeyedItem[]
CommonPrefixes?: S3PrefixedItem[]
}
function decodeS3UrlValue(value: string | undefined): string | undefined {
if (value == null) return value
try {
return decodeURIComponent(value)
} catch {
return value
}
}
export function decodeS3UrlEncodedObjectList<T extends S3ObjectListResponse>(response: T): T {
if (response.EncodingType !== "url") return response
response.Prefix = decodeS3UrlValue(response.Prefix)
response.Delimiter = decodeS3UrlValue(response.Delimiter)
response.StartAfter = decodeS3UrlValue(response.StartAfter)
response.Contents?.forEach((item) => {
item.Key = decodeS3UrlValue(item.Key)
})
response.CommonPrefixes?.forEach((item) => {
item.Prefix = decodeS3UrlValue(item.Prefix)
})
return response
}
export function decodeS3UrlEncodedObjectVersions<T extends S3ObjectVersionsResponse>(response: T): T {
if (response.EncodingType !== "url") return response
response.Prefix = decodeS3UrlValue(response.Prefix)
response.Delimiter = decodeS3UrlValue(response.Delimiter)
response.KeyMarker = decodeS3UrlValue(response.KeyMarker)
response.NextKeyMarker = decodeS3UrlValue(response.NextKeyMarker)
response.Versions?.forEach((item) => {
item.Key = decodeS3UrlValue(item.Key)
})
response.DeleteMarkers?.forEach((item) => {
item.Key = decodeS3UrlValue(item.Key)
})
response.CommonPrefixes?.forEach((item) => {
item.Prefix = decodeS3UrlValue(item.Prefix)
})
return response
}
+50
View File
@@ -0,0 +1,50 @@
import test from "node:test"
import assert from "node:assert/strict"
const loadS3ObjectEncoding = () => import(new URL("../../lib/s3-object-encoding.ts", import.meta.url).href)
test("decodeS3UrlEncodedObjectList restores control-character object keys", async () => {
const { decodeS3UrlEncodedObjectList } = await loadS3ObjectEncoding()
const response = decodeS3UrlEncodedObjectList({
EncodingType: "url",
Prefix: "bad%2F",
Delimiter: "%2F",
StartAfter: "%04bad-prefix%2Fbefore.txt",
Contents: [{ Key: "%04bad-prefix%2Fobject.txt" }],
CommonPrefixes: [{ Prefix: "normal%2F" }],
})
assert.equal(response.StartAfter, "\x04bad-prefix/before.txt")
assert.equal(response.Contents?.[0]?.Key, "\x04bad-prefix/object.txt")
assert.equal(response.CommonPrefixes?.[0]?.Prefix, "normal/")
assert.equal(response.Prefix, "bad/")
assert.equal(response.Delimiter, "/")
})
test("decodeS3UrlEncodedObjectVersions restores encoded key markers", async () => {
const { decodeS3UrlEncodedObjectVersions } = await loadS3ObjectEncoding()
const response = decodeS3UrlEncodedObjectVersions({
EncodingType: "url",
Prefix: "%04bad-prefix%2F",
KeyMarker: "%04bad-prefix%2Ffrom.txt",
NextKeyMarker: "%04bad-prefix%2Fnext.txt",
Versions: [{ Key: "%04bad-prefix%2Fobject.txt" }],
DeleteMarkers: [{ Key: "%04bad-prefix%2Fdeleted.txt" }],
})
assert.equal(response.Prefix, "\x04bad-prefix/")
assert.equal(response.KeyMarker, "\x04bad-prefix/from.txt")
assert.equal(response.NextKeyMarker, "\x04bad-prefix/next.txt")
assert.equal(response.Versions?.[0]?.Key, "\x04bad-prefix/object.txt")
assert.equal(response.DeleteMarkers?.[0]?.Key, "\x04bad-prefix/deleted.txt")
})
test("S3 URL decoding leaves malformed values unchanged", async () => {
const { decodeS3UrlEncodedObjectList } = await loadS3ObjectEncoding()
const response = decodeS3UrlEncodedObjectList({
EncodingType: "url",
Contents: [{ Key: "%E0%A4%A" }],
})
assert.equal(response.Contents?.[0]?.Key, "%E0%A4%A")
})