| @@ -143,12 +143,14 @@ | ||
| 143 | 143 | $credentialScope = "{$dateStamp}/{$this->region}/s3/aws4_request"; |
| 144 | 144 | |
| 145 | 145 | $hasDot = strpos($this->bucket, '.') !== false; |
| 146 | 146 | |
| 147 | - // Canonical request components | |
| 147 | + // Canonical request components. Deliberately does not ltrim() leading | |
| 148 | + // slashes: "foo", "/foo", and "//foo" are distinct S3 keys, and | |
| 149 | + // stripping the slash would redirect the customer to the wrong object. | |
| 148 | 150 | $encodedFilePath = '/' . implode('/', array_map( |
| 149 | 151 | 'rawurlencode', |
| 150 | - explode('/', ltrim($filePath, '/')) | |
| 152 | + explode('/', $filePath) | |
| 151 | 153 | )); |
| 152 | 154 | |
| 153 | 155 | // For dotted buckets, use path-style: include bucket in canonical URI |
| 154 | 156 | if ($hasDot) { |
| @@ -167,9 +169,9 @@ | ||
| 167 | 169 | 'X-Amz-SignedHeaders' => 'host', |
| 168 | 170 | ]; |
| 169 | 171 | |
| 170 | 172 | if (!empty($fileName)) { |
| 171 | - $canonicalQueryString['response-content-disposition'] = 'attachment; filename="' . $fileName . '"'; | |
| 173 | + $canonicalQueryString['response-content-disposition'] = $this->buildContentDispositionValue($fileName); | |
| 172 | 174 | } |
| 173 | 175 | |
| 174 | 176 | // Sort query parameters |
| 175 | 177 | ksort($canonicalQueryString); |
| @@ -201,8 +203,77 @@ | ||
| 201 | 203 | $url = "https://{$host}{$canonicalUri}?{$canonicalQueryStringStr}&X-Amz-Signature={$signature}"; |
| 202 | 204 | |
| 203 | 205 | return $url; |
| 204 | 206 | } |
| 207 | + | |
| 208 | + /** | |
| 209 | + * Builds an RFC 6266/RFC 5987-compliant Content-Disposition value. | |
| 210 | + * HTTP header values must be ISO-8859-1, so a raw Unicode filename (e.g. | |
| 211 | + * Bengali) in a plain filename="..." parameter makes S3 reject the | |
| 212 | + * request with "Header value cannot be represented using ISO-8859-1." | |
| 213 | + * This supplies both an ASCII-safe filename="..." fallback for clients | |
| 214 | + * that don't understand filename*, and a filename*=UTF-8''... parameter | |
| 215 | + * carrying the exact intended Unicode name that modern browsers use. | |
| 216 | + * | |
| 217 | + * The returned raw value is assigned into $canonicalQueryString as-is; | |
| 218 | + * the existing per-parameter rawurlencode() loop further down encodes | |
| 219 | + * it exactly once for the query string, so this method must not | |
| 220 | + * pre-encode the value itself (only the filename* attr-value, which is | |
| 221 | + * a distinct RFC 5987 encoding layer, not query-string encoding). | |
| 222 | + */ | |
| 223 | + private function buildContentDispositionValue(string $fileName): string | |
| 224 | + { | |
| 225 | + // Strip control/CR-LF characters so neither the raw header value nor | |
| 226 | + // the filename* payload can inject extra header content. | |
| 227 | + $sanitized = preg_replace('/[\x00-\x1F\x7F]/', '', $fileName); | |
| 228 | + if ($sanitized === null) { | |
| 229 | + $sanitized = $fileName; | |
| 230 | + } | |
| 231 | + | |
| 232 | + $asciiFileName = $this->buildAsciiFallbackFileName($sanitized); | |
| 233 | + // RFC 6266 quoted-string: backslash-escape backslashes and quotes. | |
| 234 | + $asciiFileName = addcslashes($asciiFileName, '\\"'); | |
| 235 | + | |
| 236 | + $encodedFileName = rawurlencode($sanitized); | |
| 237 | + | |
| 238 | + return 'attachment; filename="' . $asciiFileName . '"; filename*=UTF-8\'\'' . $encodedFileName; | |
| 239 | + } | |
| 240 | + | |
| 241 | + private function buildAsciiFallbackFileName(string $fileName): string | |
| 242 | + { | |
| 243 | + $extension = preg_replace('/[^\x20-\x7E]/', '', pathinfo($fileName, PATHINFO_EXTENSION)); | |
| 244 | + | |
| 245 | + // Empty or dot-only names ("", ".", "..", "...") carry no meaningful | |
| 246 | + // identity to preserve; keep the generic fallback for them. | |
| 247 | + if (preg_match('/^\.*$/', $fileName)) { | |
| 248 | + return $extension !== '' ? "download.{$extension}" : 'download'; | |
| 249 | + } | |
| 250 | + | |
| 251 | + // Already fully ASCII: preserve it exactly, including leading dots, | |
| 252 | + // underscores, hyphens, and extension punctuation (e.g. ".env", | |
| 253 | + // "_report_.txt", "report.a-b") - nothing here needs to change for | |
| 254 | + // a client that ignores filename*. | |
| 255 | + if (preg_replace('/[^\x20-\x7E]/', '', $fileName) === $fileName) { | |
| 256 | + return $fileName; | |
| 257 | + } | |
| 258 | + | |
| 259 | + // Otherwise the basename has non-ASCII content to strip. Anything | |
| 260 | + // dropped here (Bengali, Japanese, Arabic, emoji…) is fully | |
| 261 | + // preserved via filename*; legacy clients ignoring filename* never | |
| 262 | + // see this fallback. Trim the separator characters (dots, dashes, | |
| 263 | + // underscores, spaces) that content leaves dangling at the edges | |
| 264 | + // once it's gone. | |
| 265 | + $basename = pathinfo($fileName, PATHINFO_FILENAME); | |
| 266 | + $basename = preg_replace('/[^\x20-\x7E]/', '', $basename); | |
| 267 | + $basename = trim($basename, " \t\n\r\0\x0B-_."); | |
| 268 | + | |
| 269 | + if ($basename === '') { | |
| 270 | + return $extension !== '' ? "download.{$extension}" : 'download'; | |
| 271 | + } | |
| 272 | + | |
| 273 | + return $extension !== '' ? "{$basename}.{$extension}" : $basename; | |
| 274 | + } | |
| 275 | + | |
| 205 | 276 | protected function retrieveFileForDownload(string $downloadableFilePath, $bucket = null) |
| 206 | 277 | { |
| 207 | 278 | $this->bucket = $bucket; |
| 208 | 279 | $this->region = S3::getBucketRegion($this->bucket); |