Gauche:www.cgiとファイルアップロード

Gauche:www.cgiとファイルアップロード

さんのところで出た話題

問題

Kahuaでフォームベースのアップローダを作って気づいた。Windows(IE6)からファイルをアップロードすると、オリジナルファイル名が「Windows上でのフルパスから\を除いたもの」になってしまう。この現象、どこかで報告されていたような気がするんだが見つからない... まぁそもそもContent-Dispositionにフルパス名を渡してくるIEがアホたれなのだが、アプリ側でbasenameを取ろうにも、すでに\が消えててどうにもならない。うーむ...

原因

Shiro(2005/08/16 02:15:17 PDT: 私もこの問題どっかで見て確認した覚えがあります。 で、今ちょっと理由をさぐってみました。

www.cgiはMIMEのcontent-dispositionヘッダの値をstructured header field body (RFC2822, section 2.2.2) としてパーズしています。 で、アップロードされるファイル名はheader field bodyのfilenameパラメータとして 渡ってくるわけですが:

  content-disposition: form-data; name="fieldname"; filename="/foo/bar/baz"

rfc.822モジュールは最初にRFC2822 section 3の規則に従ってfield bodyを 単語に切り分けます。filenameの値は通常ダブルクオートで囲われているので section 3.2.5のQuoted stringになります。そして、Quoted string中では バックスラッシュがescape characterとして使われるので、filename中に バックスラッシュを含める時は、

  filename="foo\\bar\\baz"

のようになってなければなりません。

で、予想ですが、IEから渡って来るデータってもしかしてヘッダが

  filename="foo\bar\baz"

になってたりしないかしらん。これだとバックスラッシュが落ちる説明がつきます。

content-dispositionのfield bodyはstructuredとして解釈したら いけないのかなあ。それはあんまりだという気がするけれど。

(2005/08/16 03:14:11 PDT) ありがとうございます。www/cgi.scmにちょっと手を加えてパーズ前のContent-Dispositionの値をファイルに書き出してみたらまさにその通りでした。

form-data; name="file"; filename="C:\Documents and Settings\t\デスクトップ\新規作成テキスト.txt"

もうほんとに、死ねIEって感じですね。Gauche側で何か対処すべきじゃないとは思いますが、現実問題としてIE無視というわけにもいかないのが頭の痛いところです。仕方がないので、www/cgi.scmに手を加えて対処してみます。

対応案

(2005/08/16 17:57:33 PDT) 対処してみました。rfc/822.scmからパクらせていただきました。\の後ろに\もしくは"が来たときのみ\をエスケープキャラクタとして扱い、そうでない時はそのまま\を出力するというひどいやり方ですが、とりあえずfilenameにWindowsのフルパスがわたってくるようになりました。

Index: lib/www/cgi.scm
===================================================================
RCS file: /cvsroot/gauche/Gauche/lib/www/cgi.scm,v
retrieving revision 1.26
diff -u -r1.26 cgi.scm
--- lib/www/cgi.scm     12 Jul 2005 11:42:01 -0000      1.26
+++ lib/www/cgi.scm     17 Aug 2005 00:51:22 -0000
@@ -51,6 +51,7 @@
   (use gauche.charconv)
   (use gauche.vport)
   (use text.tree)
+  (use text.parse)
   (use text.html-lite)
   (use file.util)
   (export cgi-metavariables
@@ -287,9 +288,30 @@
      ((eq? action 'ignore) ignore-handler)
      (else action)))
 
+  ;; for support IE's nusty behavior.
+  (define (content-disposition-string input)
+    (let1 r (open-output-string :private? #t)
+      (define (finish) (get-output-string r))
+      (let loop ((c (peek-next-char input)))
+       (cond ((eof-object? c) (finish));; tolerate missing closing DQUOTE
+             ((char=? c #\") (read-char input) (finish)) ;; discard DQUOTE
+             ((char=? c #\\)
+              (let1 c (peek-next-char input)
+                (cond ((eof-object? c) (finish)) ;; tolerate stray backslash
+                      (else
+                       (unless (char-set-contains? #[\\\"] c)
+                         (write-char #\\ r))
+                       (write-char c r)
+                       (loop (peek-next-char input))))))
+             (else (write-char c r) (loop (peek-next-char input)))))))
+  (define *content-disposition-tokenizers*
+    `((#[\"] . ,content-disposition-string)
+      (,*rfc822-atext-chars* . ,rfc822-dot-atom)))
+
+
   (define (handle-part part-info inp)
     (let* ((cd   (part-ref part-info "content-disposition"))
-           (opts (if cd (rfc822-field->tokens cd) '()))
+           (opts (if cd (rfc822-field->tokens cd *content-disposition-tokenizers*) '()))
            (name (cond ((member "name=" opts) => cadr) (else #f)))
            (filename (cond ((member "filename=" opts) => cadr) (else #f)))
            )

Shiro (2005/08/16 21:20:51 PDT): ありがとうございます。 ただ「#\\ の後ろに #\\ もしくは #\" が来た時のみエスケープとして扱う」 という処理の根拠が不安ですね… そもそもfilenameの値をQuoted stringとして扱うべきかどうかも はっきりとはわからないので、もすこし規格を調べてみます。

(2005/08/16 23:35:00 PDT) や、根拠はありません。単なるご都合主義です。そもそもHTTP 1.1の規格自体にはContent-Dispositionは含まれていません(参照: http://www.w3.org/Protocols/rfc2616/rfc2616-sec15.html#sec15.5 )。従って、RFC2183、RFC2047、RFC2231などをにらみつつ、存在する実装に合わせてやるしかないような気がしています。余談ですが、レスポンスヘッダのContent-Dispositionにファイル名を指定してやると、ダウンロードファイル名のデフォルトを指定することができます。ところが、ここに日本語ファイル名を指定したいという要望が出てきたりするわけです。まっとうにやるなら、RFC2047やRFC2231で日本語ファイル名をエンコードすればよさそうで、実際、Firefoxはいずれの場合もちゃんと指定した日本語ファイル名でダウンロードしてくれます。しかし、IEはいずれもダメで、Shift_JISでファイル名を生書きするか、UTF-8なファイル名を単にURIエンコードして渡してやる必要があります。以下はその実験に使ったコード片です。本当は78bytes以下でフォールディングしてやる必要があるはずですが手を抜いています。

  (define (raw-word word charset)
    (if (ces-equivalent? charset (gauche-character-encoding) #t)
        word
        (ces-convert word (gauche-character-encoding) charset)))

  (define (mime-encode-word word . args)
    (define (%do-encoding word charset encoding proc)
      (with-output-to-string
        (lambda ()
          (format #t "=?~a?~s?~a?=" charset encoding (proc (raw-word word charset))))))
    (let-keywords* args
        ((charset (gauche-character-encoding))
         (encoding :b))
      (case encoding
        ((:b :B) (%do-encoding word charset 'B base64-encode-string))
        ((:q :Q) (%do-encoding word charset 'Q quoted-printable-encode-string))
        (else (errorf #`"Unsupported encoding: \"~s\"" encoding)))))

  (define (rfc2231-encode-word word . args)
    (let-keywords* args
        ((charset (gauche-character-encoding))
         (language ""))
      (with-output-to-string
        (lambda ()
          (format #t "~a'~a'~a" charset language (uri-encode-string (raw-word word charset)))))))

解決策

Shiro (2005/08/17 00:51:01 PDT): んーなるほど。filenameパラメータの値valueについては RFC2183に「RFC2045を見よ」とあり、RFC2045ではvalueは

    value := token / quoted-string

    token := 1*<any (US-ASCII) CHAR except SPACE, CTLs,
                or tspecials>

    tspecials :=  "(" / ")" / "<" / ">" / "@" /
                  "," / ";" / ":" / "\" / <">
                  "/" / "[" / "]" / "?" / "="
                  ; Must be in quoted-string,
                  ; to use within parameter values

となってるんで、これははっきり「IEがダメ」ですな。 で、多分まともにquoted-stringで送って来るクライアントは、 バックスラッシュエスケープを使うのは#\\か#\"をエスケープする時くらいの もんだろう、と推測すると、 「#\\ の後ろに #\\ もしくは #\" が来た時のみエスケープとして扱う」 はプラグマティックな解としてはアリだと思います。 従って、この方針で直しときます。

More ...