-
-
Notifications
You must be signed in to change notification settings - Fork 87
feat: Built-in s3 key sanitizer (when creating files) #337
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
@@ -47,6 +47,70 @@ async function buildDirectAccessUrl(baseUrl, baseUrlFileKey, presignedUrl, confi | |||||||||||||||||||||||||||||||||||||||||||||
return directAccessUrl; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
function cleanS3Key(key) { | ||||||||||||||||||||||||||||||||||||||||||||||
// Single-pass character replacement using a lookup map for better performance | ||||||||||||||||||||||||||||||||||||||||||||||
// See: https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-keys.html#object-key-guidelines | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
const charMap = { | ||||||||||||||||||||||||||||||||||||||||||||||
'\\': '-', | ||||||||||||||||||||||||||||||||||||||||||||||
'{': '-', '}': '-', | ||||||||||||||||||||||||||||||||||||||||||||||
'^': '-', '`': '-', '[': '-', ']': '-', '"': '-', '<': '-', '>': '-', | ||||||||||||||||||||||||||||||||||||||||||||||
'~': '-', '#': '-', '|': '-', '%': '-', | ||||||||||||||||||||||||||||||||||||||||||||||
'&': '-and-', | ||||||||||||||||||||||||||||||||||||||||||||||
'$': '-dollar-', | ||||||||||||||||||||||||||||||||||||||||||||||
'@': '-at-', | ||||||||||||||||||||||||||||||||||||||||||||||
'=': '-equals-', | ||||||||||||||||||||||||||||||||||||||||||||||
';': '-', ':': '-', '+': '-plus-', ',': '-', '?': '-' | ||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
let result = ''; | ||||||||||||||||||||||||||||||||||||||||||||||
let lastWasHyphen = false; | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
for (let i = 0; i < key.length; i++) { | ||||||||||||||||||||||||||||||||||||||||||||||
const char = key[i]; | ||||||||||||||||||||||||||||||||||||||||||||||
const charCode = char.charCodeAt(0); | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Skip non-printable ASCII and extended ASCII | ||||||||||||||||||||||||||||||||||||||||||||||
if ((charCode >= 0 && charCode <= 31) || charCode === 127 || charCode >= 128) { | ||||||||||||||||||||||||||||||||||||||||||||||
continue; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Handle whitespace - convert to single hyphen | ||||||||||||||||||||||||||||||||||||||||||||||
if (/\s/.test(char)) { | ||||||||||||||||||||||||||||||||||||||||||||||
if (!lastWasHyphen) { | ||||||||||||||||||||||||||||||||||||||||||||||
result += '-'; | ||||||||||||||||||||||||||||||||||||||||||||||
lastWasHyphen = true; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
continue; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Replace problematic characters | ||||||||||||||||||||||||||||||||||||||||||||||
if (charMap[char]) { | ||||||||||||||||||||||||||||||||||||||||||||||
const replacement = charMap[char]; | ||||||||||||||||||||||||||||||||||||||||||||||
if (replacement === '-') { | ||||||||||||||||||||||||||||||||||||||||||||||
if (!lastWasHyphen) { | ||||||||||||||||||||||||||||||||||||||||||||||
result += '-'; | ||||||||||||||||||||||||||||||||||||||||||||||
lastWasHyphen = true; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
} else { | ||||||||||||||||||||||||||||||||||||||||||||||
result += replacement; | ||||||||||||||||||||||||||||||||||||||||||||||
lastWasHyphen = false; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
continue; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Keep safe characters | ||||||||||||||||||||||||||||||||||||||||||||||
result += char; | ||||||||||||||||||||||||||||||||||||||||||||||
lastWasHyphen = false; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Remove leading/trailing hyphens and periods | ||||||||||||||||||||||||||||||||||||||||||||||
result = result.replace(/^[-\.]+|[-\.]+$/g, ''); | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Ensure we don't end up with an empty string | ||||||||||||||||||||||||||||||||||||||||||||||
return result || `file-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`; | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
function responseToBuffer(response) { | ||||||||||||||||||||||||||||||||||||||||||||||
return new Promise((resolve, reject) => { | ||||||||||||||||||||||||||||||||||||||||||||||
const chunks = []; | ||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -75,6 +139,7 @@ class S3Adapter { | |||||||||||||||||||||||||||||||||||||||||||||
this._presignedUrlExpires = parseInt(options.presignedUrlExpires, 10); | ||||||||||||||||||||||||||||||||||||||||||||||
this._encryption = options.ServerSideEncryption; | ||||||||||||||||||||||||||||||||||||||||||||||
this._generateKey = options.generateKey; | ||||||||||||||||||||||||||||||||||||||||||||||
this._cleanKey = options.cleanKey; | ||||||||||||||||||||||||||||||||||||||||||||||
this._endpoint = options.s3overrides?.endpoint; | ||||||||||||||||||||||||||||||||||||||||||||||
// Optional FilesAdaptor method | ||||||||||||||||||||||||||||||||||||||||||||||
this.validateFilename = options.validateFilename; | ||||||||||||||||||||||||||||||||||||||||||||||
|
@@ -141,15 +206,23 @@ class S3Adapter { | |||||||||||||||||||||||||||||||||||||||||||||
// For a given config object, filename, and data, store a file in S3 | ||||||||||||||||||||||||||||||||||||||||||||||
// Returns a promise containing the S3 object creation response | ||||||||||||||||||||||||||||||||||||||||||||||
async createFile(filename, data, contentType, options = {}) { | ||||||||||||||||||||||||||||||||||||||||||||||
let finalKey = filename; | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Apply generateKey if provided | ||||||||||||||||||||||||||||||||||||||||||||||
if (this._generateKey instanceof Function) { | ||||||||||||||||||||||||||||||||||||||||||||||
finalKey = this._generateKey(filename, contentType, options); | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
// Apply key cleaning if enabled (after generateKey) | ||||||||||||||||||||||||||||||||||||||||||||||
if (this._cleanKey) { | ||||||||||||||||||||||||||||||||||||||||||||||
finalKey = cleanS3Key(finalKey); | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
Comment on lines
+209
to
+220
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 💡 Verification agent 🧩 Analysis chainSupport async generateKey to avoid surprising breakage If a consumer provides an async generateKey, the current code will pass a Promise to cleanS3Key and to S3 as the Key. Since createFile is already async, await generateKey to support both sync and async implementations. - if (this._generateKey instanceof Function) {
- finalKey = this._generateKey(filename, contentType, options);
- }
+ if (this._generateKey instanceof Function) {
+ finalKey = await this._generateKey(filename, contentType, options);
+ } 🏁 Script executed: #!/bin/bash
# Locate createFile definition and check if it's async
rg -n "createFile" -C3 index.js | sed -n '1,30p'
# Specifically search for 'async createFile' in index.js
rg -n "async createFile" -n index.js
# Find where cleanS3Key is defined
rg -R "cleanS3Key" -n . Length of output: 879 Await async generateKey in createFile Since Locations to update:
Apply this diff: - if (this._generateKey instanceof Function) {
- finalKey = this._generateKey(filename, contentType, options);
- }
+ if (this._generateKey instanceof Function) {
+ finalKey = await this._generateKey(filename, contentType, options);
+ } This ensures both sync and async 📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents
|
||||||||||||||||||||||||||||||||||||||||||||||
const params = { | ||||||||||||||||||||||||||||||||||||||||||||||
Bucket: this._bucket, | ||||||||||||||||||||||||||||||||||||||||||||||
Key: this._bucketPrefix + filename, | ||||||||||||||||||||||||||||||||||||||||||||||
Key: this._bucketPrefix + finalKey, | ||||||||||||||||||||||||||||||||||||||||||||||
Body: data, | ||||||||||||||||||||||||||||||||||||||||||||||
}; | ||||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||||
if (this._generateKey instanceof Function) { | ||||||||||||||||||||||||||||||||||||||||||||||
params.Key = this._bucketPrefix + this._generateKey(filename); | ||||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||||
if (this._fileAcl) { | ||||||||||||||||||||||||||||||||||||||||||||||
if (this._fileAcl === 'none') { | ||||||||||||||||||||||||||||||||||||||||||||||
delete params.ACL; | ||||||||||||||||||||||||||||||||||||||||||||||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -867,6 +867,73 @@ describe('S3Adapter tests', () => { | |
expect(commandArg).toBeInstanceOf(PutObjectCommand); | ||
expect(commandArg.input.ACL).toBeUndefined(); | ||
}); | ||
|
||
it('should clean S3 keys when cleanKey option is enabled', async () => { | ||
const options = { | ||
bucket: 'bucket-1', | ||
cleanKey: true | ||
}; | ||
const s3 = new S3Adapter(options); | ||
s3ClientMock.send.and.returnValue(Promise.resolve({})); | ||
s3._s3Client = s3ClientMock; | ||
|
||
// Test filename with problematic characters | ||
const problematicFilename = 'test file{with}[bad]chars<>&%?.txt'; | ||
await s3.createFile(problematicFilename, 'hello world', 'text/utf8', {}); | ||
|
||
expect(s3ClientMock.send).toHaveBeenCalledTimes(2); | ||
const commands = s3ClientMock.send.calls.all(); | ||
const commandArg = commands[1].args[0]; | ||
expect(commandArg).toBeInstanceOf(PutObjectCommand); | ||
|
||
// Should have cleaned the filename | ||
const expectedCleanKey = 'test-file-with-chars-and-.txt'; | ||
expect(commandArg.input.Key).toBe(expectedCleanKey); | ||
}); | ||
|
||
it('should clean generated keys when cleanKey option is enabled', async () => { | ||
const options = { | ||
bucket: 'bucket-1', | ||
cleanKey: true, | ||
generateKey: (filename) => `generated/${filename.toUpperCase()}` | ||
}; | ||
const s3 = new S3Adapter(options); | ||
s3ClientMock.send.and.returnValue(Promise.resolve({})); | ||
s3._s3Client = s3ClientMock; | ||
|
||
const problematicFilename = 'test{bad}.txt'; | ||
await s3.createFile(problematicFilename, 'hello world', 'text/utf8', {}); | ||
|
||
expect(s3ClientMock.send).toHaveBeenCalledTimes(2); | ||
const commands = s3ClientMock.send.calls.all(); | ||
const commandArg = commands[1].args[0]; | ||
expect(commandArg).toBeInstanceOf(PutObjectCommand); | ||
|
||
// Should have cleaned the generated key (not the original filename) | ||
const expectedCleanKey = 'generated/TEST-BAD-.TXT'; | ||
expect(commandArg.input.Key).toBe(expectedCleanKey); | ||
}); | ||
|
||
it('should not clean keys when cleanKey option is disabled', async () => { | ||
const options = { | ||
bucket: 'bucket-1', | ||
cleanKey: false // explicitly disabled | ||
}; | ||
const s3 = new S3Adapter(options); | ||
s3ClientMock.send.and.returnValue(Promise.resolve({})); | ||
s3._s3Client = s3ClientMock; | ||
|
||
const problematicFilename = 'test file{with}[bad]chars.txt'; | ||
await s3.createFile(problematicFilename, 'hello world', 'text/utf8', {}); | ||
|
||
expect(s3ClientMock.send).toHaveBeenCalledTimes(2); | ||
const commands = s3ClientMock.send.calls.all(); | ||
const commandArg = commands[1].args[0]; | ||
expect(commandArg).toBeInstanceOf(PutObjectCommand); | ||
|
||
// Should preserve original filename | ||
expect(commandArg.input.Key).toBe(problematicFilename); | ||
}); | ||
Comment on lines
+917
to
+936
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🛠️ Refactor suggestion Non-cleaning path validated; please add Unicode and empty-result edge cases Two additional edge-cases worth locking down:
I can contribute tests for these scenarios if helpful. 🤖 Prompt for AI Agents
|
||
}); | ||
|
||
describe('handleFileStream', () => { | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
cleanS3Key: solid baseline, but consider preserving Unicode and tightening post-processing
The implementation is efficient and readable. A few improvements will make it safer and more broadly compatible with S3:
Suggested diffs:
Example (outside the function):
and inside the function:
🤖 Prompt for AI Agents