multipart/form-dataでファイルをPOSTするとファイル名の\n・\r・"はエスケープされる

# 結論
タイトル通り、HTTPのmultipart/form-dataでファイルをPOSTすると、ファイル名（とフィールド名）に含まれる `\n`(LF)・`\r`(CR)・`"`は以下のように置換される。


| 置換前(16進数表記) | 置換後 |
|-|-|
| `\n` (0x0A) | `%0A` |
| `\r` (0x0D) | `%0D` |
| `"`  (0x22) | `%22` |

`%`自体は置換されないので、例えば `%22.txt` と `".txt` は同じファイル名で送られる。


仕様：[HTML Standard](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#multipart-form-data)




# 仕様発見までの流れ

ユーザーから受け取ったファイルのファイル名をKeyに使ってAWS S3に保存するとき、いたずらされるか調査しよう！
↓
ダブルクォーテーション逝ったあああああああああああああああああああ
↓
開発者ツールで見るとリクエストの時点で置換されてるし仕様か？
↓
仕様だった

# 実験

## ファイルとサーバーを用意

```index.php
<?php
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    readfile('index.html');
    return false;
}
header("Content-type: text/plain; charset=UTF-8");

echo '$_FILES', PHP_EOL;
var_dump($_FILES);
echo '$_POST', PHP_EOL;
var_dump($_POST);

$filenames = [];
foreach($_FILES as $file) $filenames[] = $file['name'];
var_dump(count($filenames), count(array_unique($filenames)));
?>
```

```index.html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta http-equiv="X-UA-Compatible" content="IE=edge">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <link href="data:image/x-icon;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQEAYAAABPYyMiAAAABmJLR0T///////8JWPfcAAAACXBIWXMAAABIAAAASABGyWs+AAAAF0lEQVRIx2NgGAWjYBSMglEwCkbBSAcACBAAAeaR9cIAAAAASUVORK5CYII=" rel="icon" type="image/x-icon" />
  <title>post-test</title>
</head>
<body>
  <div>
    <h1>enctype="application/x-www-form-urlencoded"</h1>
    <form action="/" method="post" enctype="application/x-www-form-urlencoded">
      <input type="file" name="hoge" id="hoge">
      <input type="file" name="fuga" id="fuga">
      <input type="submit">
    </form>
  </div>
  <div>
    <h1>enctype="multipart/form-data"</h1>
    <form action="/" method="post" enctype="multipart/form-data">
      <input type="file" name="hoge" id="hoge">
      <input type="file" name="fuga" id="fuga">
      <input type="submit">
    </form>
  </div>
</body>
</html>
```

```shell
echo 1 > \".txt
echo 2 > %22.txt
php -S localhost:8001
```

## enctype="application/x-www-form-urlencoded"

```
$_FILES
array(0) {
}
$_POST
array(2) {
  ["hoge"]=>
  string(5) "".txt"
  ["fuga"]=>
  string(7) "%22.txt"
}
int(0)
int(0)
```

enctype="application/x-www-form-urlencoded"（enctype無指定時のデフォルト）だと、 input[type="file"]でファイル名だけ送信されます。
この場合はエスケープはされません。

## enctype="multipart/form-data"

リクエストボディ

```
-----------------------------1364817961314838981838425257
Content-Disposition: form-data; name="hoge"; filename="%22.txt"
Content-Type: text/plain

1

-----------------------------1364817961314838981838425257
Content-Disposition: form-data; name="fuga"; filename="%22.txt"
Content-Type: text/plain

2

-----------------------------1364817961314838981838425257--

```

リクエストの段階でエスケープされていることが分かります、

```
$_FILES
array(2) {
  ["hoge"]=>
  array(5) {
    ["name"]=>
    string(7) "%22.txt"
    ["type"]=>
    string(10) "text/plain"
    ["tmp_name"]=>
    string(66) "/private/var/folders/jh/lv3h8pn90m9345hrjpc29ldm0000gp/T/phpkZp3Dp"
    ["error"]=>
    int(0)
    ["size"]=>
    int(2)
  }
  ["fuga"]=>
  array(5) {
    ["name"]=>
    string(7) "%22.txt"
    ["type"]=>
    string(10) "text/plain"
    ["tmp_name"]=>
    string(66) "/private/var/folders/jh/lv3h8pn90m9345hrjpc29ldm0000gp/T/phpXOUgDg"
    ["error"]=>
    int(0)
    ["size"]=>
    int(2)
  }
}
$_POST
array(0) {
}
int(2)
int(1)
```

サーバー側はエスケープされたファイル名をデコードすることはなく、ファイル名だけ見れば全く同じになっていることが分かります。

## おまけ（cURL）

```shell
curl -V
curl 7.64.1 (x86_64-apple-darwin20.0) libcurl/7.64.1 (SecureTransport) LibreSSL/2.8.3 zlib/1.2.11 nghttp2/1.41.0
Release-Date: 2019-03-27
Protocols: dict file ftp ftps gopher http https imap imaps ldap ldaps pop3 pop3s rtsp smb smbs smtp smtps telnet tftp 
Features: AsynchDNS GSS-API HTTP2 HTTPS-proxy IPv6 Kerberos Largefile libz MultiSSL NTLM NTLM_WB SPNEGO SSL UnixSockets
```

```shell
curl -F 'hoge=@".txt' -F 'fuga=@%22.txt' --trace-ascii - "http://localhost:8001/" 
== Info:   Trying ::1...
== Info: TCP_NODELAY set
== Info: Connected to localhost (::1) port 8001 (#0)
=> Send header, 186 bytes (0xba)
0000: POST / HTTP/1.1
0011: Host: localhost:8001
0027: User-Agent: curl/7.64.1
0040: Accept: */*
004d: Content-Length: 323
0062: Content-Type: multipart/form-data; boundary=--------------------
00a2: ----cce59dfe06a05f5c
00b8: 
=> Send data, 323 bytes (0x143)
0000: --------------------------cce59dfe06a05f5c
002c: Content-Disposition: form-data; name="hoge"; filename="\".txt"
006c: Content-Type: text/plain
0086: 
0088: 
008a: --------------------------cce59dfe06a05f5c
00b6: Content-Disposition: form-data; name="fuga"; filename="%22.txt"
00f7: Content-Type: text/plain
0111: 
0113: 
0115: --------------------------cce59dfe06a05f5c--
== Info: We are completely uploaded and fine
<= Recv header, 17 bytes (0x11)
0000: HTTP/1.1 200 OK
<= Recv header, 22 bytes (0x16)
0000: Host: localhost:8001
<= Recv header, 37 bytes (0x25)
0000: Date: Fri, 28 Jan 2022 10:40:47 GMT
<= Recv header, 19 bytes (0x13)
0000: Connection: close
<= Recv header, 26 bytes (0x1a)
0000: X-Powered-By: PHP/7.4.26
<= Recv header, 41 bytes (0x29)
0000: Content-type: text/plain; charset=UTF-8
<= Recv header, 2 bytes (0x2)
0000: 
<= Recv data, 589 bytes (0x24d)
0000: $_FILES.array(2) {.  ["hoge"]=>.  array(5) {.    ["name"]=>.    
0040: string(5) "".txt".    ["type"]=>.    string(10) "text/plain".   
0080:  ["tmp_name"]=>.    string(66) "/private/var/folders/jh/lv3h8pn9
00c0: 0m9345hrjpc29ldm0000gp/T/phpteQsKs".    ["error"]=>.    int(0). 
0100:    ["size"]=>.    int(0).  }.  ["fuga"]=>.  array(5) {.    ["nam
0140: e"]=>.    string(7) "%22.txt".    ["type"]=>.    string(10) "tex
0180: t/plain".    ["tmp_name"]=>.    string(66) "/private/var/folders
01c0: /jh/lv3h8pn90m9345hrjpc29ldm0000gp/T/phpTAoz4x".    ["error"]=>.
0200:     int(0).    ["size"]=>.    int(0).  }.}.$_POST.array(0) {.}.i
0240: nt(2).int(2).
$_FILES
array(2) {
  ["hoge"]=>
  array(5) {
    ["name"]=>
    string(5) "".txt"
    ["type"]=>
    string(10) "text/plain"
    ["tmp_name"]=>
    string(66) "/private/var/folders/jh/lv3h8pn90m9345hrjpc29ldm0000gp/T/phpteQsKs"
    ["error"]=>
    int(0)
    ["size"]=>
    int(0)
  }
  ["fuga"]=>
  array(5) {
    ["name"]=>
    string(7) "%22.txt"
    ["type"]=>
    string(10) "text/plain"
    ["tmp_name"]=>
    string(66) "/private/var/folders/jh/lv3h8pn90m9345hrjpc29ldm0000gp/T/phpTAoz4x"
    ["error"]=>
    int(0)
    ["size"]=>
    int(0)
  }
}
$_POST
array(0) {
}
int(2)
int(2)
== Info: Closing connection 0
```

curl だとファイル名をエスケープしないかもしれません。

curlでファイル名に改行文字をファイル名に含むファイルを選択するのが面倒だったので、そちらの実験はしていません。