Multipart and File Uploads
Multipart form encoding, parsing file uploads as bytes, and safely hosting uploaded files
Multipart Forms
HTML Forms - POST
- Last time we saw forms:
- Submit the content of the form in the body of the POST request
POST /form-path HTTP/1.1
Content-Length: 27
Content-Type: application/x-www-form-urlencoded
commenter=Jesse&comment=Good+morning%21
HTML Forms - POST
- You can specify the encoding type for form submissions
- Default is url encoded (Special characters are % encoded)
POST /form-path HTTP/1.1
Content-Length: 27
Content-Type: application/x-www-form-urlencoded
commenter=Jesse&comment=Good+morning%21
HTML Forms - POST
- url encoding cannot be used to upload files from a form!
- The browser will only send the filename, not the contents of the file
- We need something else for file uploads
POST /form-path HTTP/1.1
Content-Length: 27
Content-Type: application/x-www-form-urlencoded
commenter=Jesse&comment=Good+morning%21
HTML Forms - POST
- Specify multipart encoding to receive each input separately in the body of the request
- With multipart encoding, the browser will send the contents of files
HTML Forms - POST
- Our server receives a multipart form request in this format
POST /form-path HTTP/1.1
Content-Length: 252
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryfkz9sCA6fR3CAHN4
------WebKitFormBoundaryfkz9sCA6fR3CAHN4
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundaryfkz9sCA6fR3CAHN4
Content-Disposition: form-data; name="comment"
Good morning!
------WebKitFormBoundaryfkz9sCA6fR3CAHN4--
HTML Forms - POST
- Content-Type specifies a string that separates each input
- Each input has its own headers
- Great for submitting different types of data in the same form
- Required for file uploads
POST /form-path HTTP/1.1
Content-Length: 252
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryfkz9sCA6fR3CAHN4
------WebKitFormBoundaryfkz9sCA6fR3CAHN4
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundaryfkz9sCA6fR3CAHN4
Content-Disposition: form-data; name="comment"
Good morning!
------WebKitFormBoundaryfkz9sCA6fR3CAHN4--
File Uploads
File Uploads
- We must use multipart/form-data to upload files
- If not, browser only sends the filename
- Add an input with type "file"
- The browser does the rest
- Users will be able to choose a file to send
File Uploads
- When our server receives the file, it will appear in one of the parts of the multipart POST request
- The content type will tell us the type of file
- The body of the part will contain all the bytes of that file
- Can write these bytes to a new file on our server to save that file
------WebKitFormBoundarygVWEOc5JIyJ1qthO
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarygVWEOc5JIyJ1qthO
Content-Disposition: form-data; name="upload"; filename="discord2.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarygVWEOc5JIyJ1qthO--
File Uploads
- When receiving the bytes of a binary file, do not apply any encodings
- When we received bytes representing text, we decoded it by interpreting the bytes as UTF-8 encoded text
- When receiving files, we are often interested in the raw bytes (Ex. Images, videos)
- The files will be encoded with algorithms other than UTF-8 (Ex. png, jpeg, mp4)
- Attempting to treat a binary file as a UTF-8 String will corrupt the data!
Parsing Multipart
Parsing Multipart
- Your goal is to parse HTTP POST requests in this format
- Without corrupting the image
- Let's walk through the steps you'll need to take
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--
Parsing Multipart
- Identify the request using the method and path
- This is a POST request for /form-path
- Based on our HTML, we know to treat this as a form submission
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Parse the headers and read the length of the content
- This will be the number of bytes that need to be read from the body
- Follows the same protocol as your HTTP responses
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Refer to your content length and read that many bytes from the body of the request
- Important: Do not attempt to parse the body until you've read this many bytes from the body (More details in the next lecture)
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Read the Content-Type header to get the boundary
- Boundary is specified in addition to the MIME type
- Recall that we used the same format to specify charset=utf-8
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- The body will consist any number of parts, each separated by the boundary
- The browser will guarantee that the boundary is not contained in any of the data being sent
- This example has 2 parts
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- The parts are separated by the boundary with two leading dash characters
-- - Each boundary must be at the beginning of a line
- The full boundary is CRLF +
--+<boundary>- Except the first one which is already preceded by a CRLF from the blank line separating the headers and body
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- The end of the last boundary is marked by the full boundary plus two trailing
-characters - The last boundary is CRLF +
--+<boundary>+-- - Can have a trailing CRLF after the last boundary that should be ignored
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Each part of the request will follow a similar format to HTTP requests:
- Any number of headers
- One blank line
- The content of the part
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- The headers should include a Content-Disposition
- Provides the name of the part in quotes
- Name matches the name from your form
- For files, the original filename is provided in quotes
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Content-Type is optional for parts
- If excluded, the default MIME type is text/plain
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Content-Length is not included in the parts
- The purpose of Content-Length is to ensure we've received the full body before parsing
- Already read the full body using the HTTP Content-Length
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--
Parsing Multipart
- When reading the bytes of a [non-text] file
- Never, never, never encode the bytes as a string!
- But wait.. how do I parse all this stuff without making it a String?..
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Parsing Multipart
- Parse in bytes!
- When parsing bytes that contain non-text data:
- We'll use byte-parsing, not String-parsing
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--
Parsing Multipart
- You receive the request as an array of bytes
- Scan this array for the bytes you're looking for
- Create sub-arrays to extract data
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--
Parsing Multipart
- Example: To extract the headers you would find the first instance of the
\r\n\r\nString and read everything before it - Encode
\r\n\r\ninto bytes and search the byte array for this sequence of bytes, then create a new array with everything before that sequence - Recall that headers are ASCII only and can safely be treated as text
POST /form-path HTTP/1.1
Content-Length: 9937
Content-Type: multipart/form-data; boundary=----WebKitFormBoundarycriD3u6M0UuPR1ia
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="commenter"
Jesse
------WebKitFormBoundarycriD3u6M0UuPR1ia
Content-Disposition: form-data; name="upload"; filename="discord.png"
Content-Type: image/png
<bytes_of_the_file>
------WebKitFormBoundarycriD3u6M0UuPR1ia--Saving and Hosting Files
File Uploads
To save a file on your server:
- Save the file to disk
- Bad practice to store files in a database
- Generate a naming convention for your saved files
- Do not use the user supplied filename
- Attacker could choose a filename of
/path/to/secretto read any file from your server - Even trustworthy users might create name collisions with each other (e.g. Two users upload
pfp.png)
- Attacker could choose a filename of
- Store only the filename in your database
- When you pull a record from the database, it will tell you which filename to use and that file can be read from disk
Image Uploads
- To host user supplied images on your site:
- Pull the filename from the database and use this as the src of the img element in your HTML
- Using HTML templates (rendered either back end or front end)
- The browser will read the src attribute and send a request for the file path
- Host the bytes of the file at that path
Potential Attacks
File Signatures
- Once you have the bytes, what type of file is it?
- The file extension and the part's Content-Type are chosen by the user
- An attacker can upload
attack.jsrenamed tocat.jpg
- An attacker can upload
- The first bytes of most file types are a fixed signature
- Part of the content, so it's harder to fake
- Check the first bytes against the types you allow
bytes.HasPrefix(content, []byte{0xFF, 0xD8, 0xFF})| Type | First bytes |
|---|---|
| JPEG | FF D8 FF |
| PNG | 89 50 4E 47 0D 0A 1A 0A |
| GIF | GIF87a or GIF89a |
| MP4 | ftyp starting at byte 4 |
Requesting Arbitrary Files
- You have a path that effectively takes requests
/public/[filename]and serversfilename- An attacker requests
/public/~/.ssh/id_ed25519 - You happily server them your private encryption key
- An attacker requests
- Prevent the attack by ensuring that all requests for files are in a public directory after resolving the file path
- Get the absolute directory of the path and ensure that you are still in the
/path/to/project/publicdirectory - If not, return a 400-level response (Probably 400 Bad Request)
- Get the absolute directory of the path and ensure that you are still in the