UNPKG

s3-key-validator

Version:

AWS S3 object key validation library for TypeScript

536 lines (525 loc) 14.8 kB
// src/core/result.ts var ValidationResultBuilder = class { constructor() { this.errors = []; this.warnings = []; this.suggestions = []; } addError(error) { this.errors.push(error); return this; } addErrors(errors) { this.errors.push(...errors); return this; } addWarning(warning) { this.warnings.push(warning); return this; } addWarnings(warnings) { this.warnings.push(...warnings); return this; } addSuggestion(suggestion) { this.suggestions.push(suggestion); return this; } addSuggestions(suggestions) { this.suggestions.push(...suggestions); return this; } build() { return { isValid: this.errors.length === 0, errors: this.errors, warnings: this.warnings.length > 0 ? this.warnings : void 0, suggestions: this.suggestions.length > 0 ? this.suggestions : void 0 }; } static createValid() { return { isValid: true, errors: [] }; } static createInvalid(errors) { return { isValid: false, errors }; } }; // src/validators/character.ts var CharacterValidator = class { static validate(key, options) { const errors = []; const warnings = []; for (let i = 0; i < key.length; i++) { const char = key[i]; const result = this.validateCharacter(char, i, options); if (result.error) { errors.push(result.error); } if (result.warning) { warnings.push(result.warning); } } return { errors, warnings }; } static validateCharacter(char, position, options) { const charCode = char.charCodeAt(0); if (this.isSafeCharacter(char)) { return {}; } if (this.isForbiddenCharacter(char)) { return { error: { type: "CHARACTER", message: `Forbidden character '${char}' at position ${position}`, position, character: char } }; } if (this.isSpecialCharacter(char)) { const allowed = this.isSpecialCharacterAllowed(char, options); if (!allowed) { return { error: { type: "CHARACTER", message: `Special character '${char}' not allowed at position ${position}`, position, character: char } }; } return { warning: { type: "COMPATIBILITY", message: `Special character '${char}' may cause compatibility issues` } }; } if (this.isLanguageCharacter(charCode)) { const allowed = this.isLanguageCharacterAllowed(charCode, options); if (!allowed) { return { error: { type: "CHARACTER", message: `Multi-byte character '${char}' not allowed at position ${position}`, position, character: char } }; } return { warning: { type: "ENCODING", message: "Non-ASCII characters may cause compatibility issues" } }; } if (options.additionalChars?.includes(char)) { return {}; } return { error: { type: "CHARACTER", message: `Invalid character '${char}' at position ${position}`, position, character: char } }; } static isSafeCharacter(char) { return /^[0-9a-zA-Z!\-_.*'()]$/.test(char); } static isForbiddenCharacter(char) { return /^[\\{}^%`\]">~<#|]$/.test(char); } static isSpecialCharacter(char) { return Object.values(this.SPECIAL_CHARS).includes(char); } static isSpecialCharacterAllowed(char, options) { const specialChars = options.specialChars || {}; switch (char) { case this.SPECIAL_CHARS.slash: return specialChars.allowSlash || false; case this.SPECIAL_CHARS.colon: return specialChars.allowColon || false; case this.SPECIAL_CHARS.space: return specialChars.allowSpace || false; case this.SPECIAL_CHARS.at: return specialChars.allowAt || false; case this.SPECIAL_CHARS.ampersand: return specialChars.allowAmpersand || false; case this.SPECIAL_CHARS.dollar: return specialChars.allowDollar || false; default: return false; } } static isLanguageCharacter(charCode) { const ranges = this.UNICODE_RANGES; return charCode >= ranges.hiragana[0] && charCode <= ranges.hiragana[1] || charCode >= ranges.katakana[0] && charCode <= ranges.katakana[1] || charCode >= ranges.cjkUnified[0] && charCode <= ranges.cjkUnified[1] || charCode >= ranges.hangul[0] && charCode <= ranges.hangul[1]; } static isLanguageCharacterAllowed(charCode, options) { const languages = options.languages || {}; const ranges = this.UNICODE_RANGES; if (languages.allowCJK) { return true; } if (languages.allowJapanese) { if (charCode >= ranges.hiragana[0] && charCode <= ranges.hiragana[1] || charCode >= ranges.katakana[0] && charCode <= ranges.katakana[1] || charCode >= ranges.cjkUnified[0] && charCode <= ranges.cjkUnified[1]) { return true; } } if (languages.allowKorean) { if (charCode >= ranges.hangul[0] && charCode <= ranges.hangul[1]) { return true; } } if (languages.allowChinese) { if (charCode >= ranges.cjkUnified[0] && charCode <= ranges.cjkUnified[1]) { return true; } } return false; } }; CharacterValidator.SAFE_CHARS = "0-9a-zA-Z!\\-_.*'()"; CharacterValidator.SPECIAL_CHARS = { slash: "/", colon: ":", space: " ", at: "@", ampersand: "&", dollar: "$" }; CharacterValidator.FORBIDDEN_CHARS = '\\\\{}^%`]">~<#|'; CharacterValidator.UNICODE_RANGES = { hiragana: [12352, 12447], katakana: [12448, 12543], cjkUnified: [19968, 40879], hangul: [44032, 55215] }; // src/validators/length.ts var LengthValidator = class { static validate(key, options) { const errors = []; const maxLength = options.maxLength || this.DEFAULT_MAX_LENGTH; if (key.length === 0) { errors.push({ type: "LENGTH", message: "Key cannot be empty" }); return errors; } const byteLength = this.getByteLength(key); if (byteLength > maxLength) { errors.push({ type: "LENGTH", message: `Key exceeds maximum length of ${maxLength} bytes (current: ${byteLength} bytes)` }); } return errors; } static getByteLength(str) { return new globalThis.TextEncoder().encode(str).length; } }; LengthValidator.DEFAULT_MAX_LENGTH = 1024; // src/validators/encoding.ts var EncodingValidator = class { static validate(key) { const errors = []; if (!this.isValidUTF8(key)) { errors.push({ type: "ENCODING", message: "Key contains invalid UTF-8 sequences" }); } const controlCharErrors = this.checkControlCharacters(key); errors.push(...controlCharErrors); return errors; } static isValidUTF8(str) { try { const encoder = new globalThis.TextEncoder(); const decoder = new globalThis.TextDecoder("utf-8", { fatal: true }); const encoded = encoder.encode(str); const decoded = decoder.decode(encoded); return decoded === str; } catch { return false; } } static checkControlCharacters(key) { const errors = []; for (let i = 0; i < key.length; i++) { const char = key[i]; const charCode = char.charCodeAt(0); if (this.isControlCharacter(charCode)) { errors.push({ type: "ENCODING", message: `Control character (U+${charCode.toString(16).toUpperCase().padStart(4, "0")}) at position ${i}`, position: i, character: char }); } } return errors; } static isControlCharacter(charCode) { return charCode >= 0 && charCode <= 31 || charCode >= 127 && charCode <= 159; } }; // src/validators/path.ts var PathValidator = class { static validate(key, options) { const errors = []; if (!options.allowDotPrefix && key.startsWith("./")) { errors.push({ type: "PATH", message: 'Keys starting with "./" are not allowed (console limitation)', position: 0 }); } if (!options.allowRelativePaths && key.includes("../")) { const position = key.indexOf("../"); errors.push({ type: "PATH", message: 'Relative path elements "../" are not allowed', position }); } const consecutiveSlashErrors = this.checkConsecutiveSlashes(key); errors.push(...consecutiveSlashErrors); const trailingSlashErrors = this.checkTrailingSlashes(key); errors.push(...trailingSlashErrors); return errors; } static checkConsecutiveSlashes(key) { const errors = []; let position = key.indexOf("//"); while (position !== -1) { errors.push({ type: "PATH", message: 'Consecutive slashes "//" are not recommended', position }); position = key.indexOf("//", position + 1); } return errors; } static checkTrailingSlashes(key) { const errors = []; if (key.endsWith("/")) { errors.push({ type: "PATH", message: "Trailing slash is not recommended for object keys", position: key.length - 1 }); } return errors; } }; // src/presets/strict.ts var strictPreset = { mode: "strict", specialChars: { allowSlash: false, allowColon: false, allowSpace: false, allowAt: false, allowAmpersand: false, allowDollar: false }, languages: { allowJapanese: false, allowKorean: false, allowChinese: false, allowCJK: false }, additionalChars: [], maxLength: 1024, allowRelativePaths: false, allowDotPrefix: false }; // src/presets/standard.ts var standardPreset = { mode: "standard", specialChars: { allowSlash: true, allowColon: false, allowSpace: false, allowAt: false, allowAmpersand: false, allowDollar: false }, languages: { allowJapanese: false, allowKorean: false, allowChinese: false, allowCJK: false }, additionalChars: [], maxLength: 1024, allowRelativePaths: false, allowDotPrefix: false }; // src/presets/permissive.ts var permissivePreset = { mode: "permissive", specialChars: { allowSlash: true, allowColon: true, allowSpace: true, allowAt: true, allowAmpersand: true, allowDollar: true }, languages: { allowJapanese: false, allowKorean: false, allowChinese: false, allowCJK: false }, additionalChars: [], maxLength: 1024, allowRelativePaths: false, allowDotPrefix: false }; // src/presets/index.ts function getPreset(mode) { switch (mode) { case "strict": return strictPreset; case "standard": return standardPreset; case "permissive": return permissivePreset; default: return standardPreset; } } // src/core/validator.ts var S3KeyValidator = class { static validate(key, options = {}) { const resolvedOptions = this.resolveOptions(options); const resultBuilder = new ValidationResultBuilder(); const lengthErrors = LengthValidator.validate(key, resolvedOptions); resultBuilder.addErrors(lengthErrors); const encodingErrors = EncodingValidator.validate(key); resultBuilder.addErrors(encodingErrors); const pathErrors = PathValidator.validate(key, resolvedOptions); resultBuilder.addErrors(pathErrors); const characterResult = CharacterValidator.validate(key, resolvedOptions); resultBuilder.addErrors(characterResult.errors); resultBuilder.addWarnings(characterResult.warnings); this.addSuggestions(resultBuilder, key, resolvedOptions); return resultBuilder.build(); } static resolveOptions(options) { if (options.mode) { const preset = getPreset(options.mode); return { ...preset, ...options, specialChars: { ...preset.specialChars, ...options.specialChars }, languages: { ...preset.languages, ...options.languages } }; } return { mode: "standard", specialChars: { allowSlash: true, allowColon: false, allowSpace: false, allowAt: false, allowAmpersand: false, allowDollar: false }, languages: { allowJapanese: false, allowKorean: false, allowChinese: false, allowCJK: false }, additionalChars: [], maxLength: 1024, allowRelativePaths: false, allowDotPrefix: false, ...options }; } static addSuggestions(resultBuilder, key, options) { if (key.includes("//")) { resultBuilder.addSuggestion("Remove consecutive slashes (//)"); } if (key.endsWith("/")) { resultBuilder.addSuggestion("Remove trailing slash"); } if (key.startsWith("./")) { resultBuilder.addSuggestion('Remove "./" prefix for better console compatibility'); } if (key.includes("../")) { resultBuilder.addSuggestion('Remove relative path elements ("../")'); } if (/[\\{}^%`]">~<#|]/.test(key)) { resultBuilder.addSuggestion('Remove forbidden characters: \\ { } ^ % ` ] " > [ ~ < # |'); } if (key.includes(" ") && !options.specialChars?.allowSpace) { resultBuilder.addSuggestion("Replace spaces with hyphens (-) or underscores (_)"); } if (key.includes(":") && !options.specialChars?.allowColon) { resultBuilder.addSuggestion("Replace colons (:) with hyphens (-) or underscores (_)"); } if (key.includes("@") && !options.specialChars?.allowAt) { resultBuilder.addSuggestion("Replace @ symbols with hyphens (-) or underscores (_)"); } } }; // src/index.ts function validateS3Key(key, options) { return S3KeyValidator.validate(key, options); } function isValidS3Key(key, options) { const result = S3KeyValidator.validate(key, options); return result.isValid; } function sanitizeS3Key(key, options) { let sanitized = key; sanitized = sanitized.replace(/[\\{}^%`\]">~<#|]/g, ""); sanitized = sanitized.replace(/\/+/g, "/"); sanitized = sanitized.replace(/\/$/, ""); if (sanitized.startsWith("./")) { sanitized = sanitized.substring(2); } sanitized = sanitized.replace(/\.\.\//g, ""); if (!options?.specialChars?.allowSpace) { sanitized = sanitized.replace(/\s+/g, "-"); } if (!options?.specialChars?.allowColon) { sanitized = sanitized.replace(/:/g, "-"); } if (!options?.specialChars?.allowAt) { sanitized = sanitized.replace(/@/g, "-"); } return sanitized; } export { isValidS3Key, permissivePreset, sanitizeS3Key, standardPreset, strictPreset, validateS3Key };