Skip to content

Commit 7f20123

Browse files
authored
Build Content-Disposition per RFC 6266 and RFC 8187 (#813)
1 parent cf185ac commit 7f20123

5 files changed

Lines changed: 87 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ error 405 do |env|
1717
end
1818
```
1919

20+
- Build the `Content-Disposition` of `send_file` per [RFC 6266](https://www.rfc-editor.org/rfc/rfc6266#section-4.3) and [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187). The filename was dropped into the quoted-string as it was: a `"` in it ended the parameter early, a non-ASCII name went out raw where the parameter is defined as ASCII, and a control character made the standard library reject the header with a `500`. `"` and `\\` are now escaped, other characters outside printable ASCII become `_` in `filename`, and when that loses anything the original name follows as `filename*=UTF-8''…`, which user agents prefer. A plain ASCII name produces the same header as before. `Kemal::Utils.content_disposition` is the builder.
2021
- `params.raw_body` returns the body of any request, not only a form or JSON one. It came back empty for `text/plain`, XML, or a request with no `Content-Type` at all, so such a body looked absent rather than unread; it is now read and cached the same way, under `max_request_body_size`. A `multipart/form-data` body is the one exception and still returns `""`, since `parse_files` streams it part by part. JSON detection now goes by media type instead of a string prefix: `application/vnd.api+json` and other `+json` types ([RFC 6839](https://www.rfc-editor.org/rfc/rfc6839)) parse into `params.json`, `Application/JSON` matches, and `application/jsonp` no longer does.
2122
- Skip the WebSocket upgrade when a `before` filter has already answered. A `halt` in a `before_all` — an authentication check answering `401` — closed the response, and `Kemal::WebSocketHandler` went on to attempt the upgrade regardless; the standard library handler raised `IO::Error: Closed stream` on the closed response. The client had its `401`, but every rejected handshake was logged as a server error. The handler now returns once it finds the response closed.
2223
- Stop confirming that a directory under `public/` exists when nothing is served for it. The standard library adds the trailing slash to a directory URL whenever its own `directory_listing` flag is on, and Kemal never set that flag from `serve_static`, so with `dir_listing` and `dir_index` both off `/admin` still answered `302 /admin/` while `/nope` answered `404` — the redirect was the only difference, and it told a scanner which directories are there. The redirect now happens only when a listing or an `index.html` would be served at the slashed URL; otherwise a directory falls through to the same `404` as a missing path.

spec/helpers_spec.cr

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,15 @@ describe "Macros" do
219219
response.headers["Content-Disposition"].should eq("attachment; filename=\"image.jpg\"")
220220
end
221221

222+
it "sends a non-ASCII filename as an RFC 8187 filename* with an ASCII fallback" do
223+
get "/" do |env|
224+
send_file env, "#{__DIR__}/asset/hello.ecr", filename: "rapor ünlü.pdf"
225+
end
226+
response = call_request_on_app(HTTP::Request.new("GET", "/"))
227+
response.status_code.should eq(200)
228+
response.headers["Content-Disposition"].should eq(%(attachment; filename="rapor _nl_.pdf"; filename*=UTF-8''rapor%20%C3%BCnl%C3%BC.pdf))
229+
end
230+
222231
it "handles multiple range requests" do
223232
get "/" do |env|
224233
send_file env, "#{__DIR__}/asset/hello.ecr"

spec/utils_spec.cr

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,4 +194,28 @@ describe Kemal::Utils do
194194
end
195195
end
196196
{% end %}
197+
198+
describe ".content_disposition" do
199+
it "quotes a plain ASCII name as it is" do
200+
Kemal::Utils.content_disposition("attachment", "report.pdf").should eq(%(attachment; filename="report.pdf"))
201+
Kemal::Utils.content_disposition("inline", "a b.txt").should eq(%(inline; filename="a b.txt"))
202+
end
203+
204+
it "escapes the quote and the backslash inside the quoted-string" do
205+
# An unescaped `"` ended the parameter early: `filename="report "final".pdf"`.
206+
Kemal::Utils.content_disposition("attachment", %(report "final".pdf)).should eq(%q(attachment; filename="report \"final\".pdf"))
207+
Kemal::Utils.content_disposition("attachment", %q(back\slash.txt)).should eq(%q(attachment; filename="back\\slash.txt"))
208+
end
209+
210+
it "adds an RFC 8187 filename* for a name outside ASCII" do
211+
Kemal::Utils.content_disposition("attachment", "rapor ünlü.pdf")
212+
.should eq(%(attachment; filename="rapor _nl_.pdf"; filename*=UTF-8''rapor%20%C3%BCnl%C3%BC.pdf))
213+
end
214+
215+
it "keeps control characters out of the header" do
216+
# A CR/LF in the name used to make the stdlib reject the header, a 500.
217+
Kemal::Utils.content_disposition("attachment", "line\r\nX: 1.txt")
218+
.should eq(%(attachment; filename="line__X: 1.txt"; filename*=UTF-8''line%0D%0AX%3A%201.txt))
219+
end
220+
end
197221
end

src/kemal/helpers/helpers.cr

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -373,7 +373,7 @@ end
373373
private def attachment(env : HTTP::Server::Context, filename : String? = nil, disposition : String? = nil)
374374
disposition = "attachment" if disposition.nil? && filename
375375
if disposition && filename
376-
env.response.headers["Content-Disposition"] = "#{disposition}; filename=\"#{File.basename(filename)}\""
376+
env.response.headers["Content-Disposition"] = Kemal::Utils.content_disposition(disposition, File.basename(filename))
377377
end
378378
end
379379

src/kemal/helpers/utils.cr

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,5 +207,57 @@ module Kemal
207207
headers["Etag"] = etag_with_coding(etag, coding)
208208
end
209209
end
210+
211+
# Builds a `Content-Disposition` field value for *filename*
212+
# ([RFC 6266 §4.3](https://www.rfc-editor.org/rfc/rfc6266#section-4.3)).
213+
#
214+
# The `filename` parameter is a quoted-string and has to stay in ASCII, so `"` and
215+
# `\\` are escaped as quoted-pairs and every other character outside printable ASCII
216+
# becomes `_`. When that loses something, the original name follows as
217+
# `filename*=UTF-8''...` percent-encoded per
218+
# [RFC 8187](https://www.rfc-editor.org/rfc/rfc8187), which user agents that
219+
# understand it prefer. A name that is plain ASCII to begin with gets only the
220+
# first form, unchanged.
221+
#
222+
# ```
223+
# Kemal::Utils.content_disposition("attachment", "report.pdf") # => %(attachment; filename="report.pdf")
224+
# Kemal::Utils.content_disposition("attachment", "rapor ü.pdf") # => %(attachment; filename="rapor _.pdf"; filename*=UTF-8''rapor%20%C3%BC.pdf)
225+
# ```
226+
def self.content_disposition(disposition : String, filename : String) : String
227+
String.build do |header|
228+
header << disposition << %(; filename=")
229+
lossy = false
230+
filename.each_char do |char|
231+
case char
232+
when '"', '\\'
233+
header << '\\' << char
234+
when ' '..'~'
235+
header << char
236+
else
237+
lossy = true
238+
header << '_'
239+
end
240+
end
241+
header << '"'
242+
243+
if lossy
244+
header << "; filename*=UTF-8''"
245+
percent_encode_attr_chars(filename, header)
246+
end
247+
end
248+
end
249+
250+
# Percent-encodes everything in *value* that is not an RFC 8187 `attr-char`.
251+
private def self.percent_encode_attr_chars(value : String, io : IO) : Nil
252+
value.each_byte do |byte|
253+
char = byte.unsafe_chr
254+
if char.ascii_alphanumeric? || "!#$&+-.^_`|~".includes?(char)
255+
io << char
256+
else
257+
io << '%'
258+
io << byte.to_s(16, upcase: true).rjust(2, '0')
259+
end
260+
end
261+
end
210262
end
211263
end

0 commit comments

Comments
 (0)