|
1 | | -# Handling File Uploads |
| 1 | +# Handling File Uploads in GraphQL |
2 | 2 |
|
3 | | -GraphQL doesn't natively support file uploads. The [GraphQL specification](https://spec.graphql.org/draft/) is transport-agnostic |
4 | | -and historically assumed `application/json`, but the evolving [GraphQL over HTTP specification](https://graphql.github.io/graphql-over-http/draft/) |
5 | | -introduces support for additional media types: `application/graphql-response+json`. |
| 3 | +GraphQL was not designed with file uploads in mind. While it’s technically possible to implement them, doing so requires |
| 4 | +extending the transport layer and introduces several risks, both in security and reliability. |
6 | 5 |
|
7 | | -Since uploading files typically requires `multipart/form-data`, adding upload capabilities still |
8 | | -means extending the HTTP layer yourself. This guide explains how to handle file uploads using |
9 | | -[`graphql-http`](https://github.com/graphql/graphql-http), a minimal, spec-compliant GraphQL server implementation for JavaScript. |
| 6 | +This guide explains why file uploads via GraphQL are problematic and presents safer alternatives. |
10 | 7 |
|
11 | | -## Why file uploads require extra work |
| 8 | +## Why uploads are challenging |
12 | 9 |
|
13 | | -A standard GraphQL request sends a query or mutation and optional variables as JSON. But file |
14 | | -uploads require binary data, which JSON can't represent. Instead, clients typically use |
15 | | -`multipart/form-data`, the same encoding used for HTML file forms. This format is incompatible |
16 | | -with how GraphQL servers like `graphql-http` handle requests by default. |
| 10 | +The [GraphQL specification](https://spec.graphql.org/draft/) is transport-agnostic and assumes requests are encoded as JSON. |
| 11 | +File uploads, by contrast, require `multipart/form-data` encoding to transfer binary data—something JSON can’t handle. |
17 | 12 |
|
18 | | -To bridge this gap, the GraphQL community developed a convention: the [GraphQL multipart |
19 | | -request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). This |
20 | | -approach allows files to be uploaded as part of a GraphQL mutation, with the server handling the |
21 | | -`multipart/form-data` payload and injecting the uploaded file into the appropriate variable. |
| 13 | +Supporting uploads over GraphQL usually involves adopting community conventions, like the |
| 14 | +[GraphQL multipart request specification](https://github.com/jaydenseric/graphql-multipart-request-spec). While useful in some |
| 15 | +environments, these solutions often introduce complexity, fragility, and security risks. |
22 | 16 |
|
23 | | -## The multipart upload format |
| 17 | +## Risks to be aware of |
24 | 18 |
|
25 | | -The multipart spec defines a three-part request format: |
| 19 | +### Memory exhaustion from repeated variables |
26 | 20 |
|
27 | | -- `operations`: A JSON string representing the GraphQL operation |
28 | | -- `map`: A JSON object that maps file field name to variable paths |
29 | | -- One or more files: Attached to the form using the same field names referenced in the `map` |
| 21 | +GraphQL operations allow the same variable to be referenced multiple times. If a file upload variable is used more than |
| 22 | +once, its stream may be consumed multiple times—or worse, not at all. This can lead to unpredictable behavior or denial of service (DoS). |
30 | 23 |
|
31 | | -### Example |
| 24 | +### Stream leaks on failed operations |
32 | 25 |
|
33 | | -```graphql |
34 | | -mutation UploadFile($file: Upload!) { |
35 | | - uploadFile(file: $file) { |
36 | | - filename |
37 | | - mimetype |
38 | | - } |
39 | | -} |
40 | | -``` |
| 26 | +GraphQL executes in phases: validation, then execution. If an error occurs during validation or authorization, your |
| 27 | +server might never reach the resolver that consumes a file stream. If file streams are left unconsumed, memory usage can |
| 28 | +spike, potentially exhausting server resources. |
41 | 29 |
|
42 | | -And the corresponding `map` field: |
| 30 | +### Cross-Site Request Forgery (CSRF) |
43 | 31 |
|
44 | | -```json |
45 | | -{ |
46 | | - "0": ["variables.file"] |
47 | | -} |
48 | | -``` |
| 32 | +`multipart/form-data` is classified as a “simple” request by CORS and does not trigger preflight checks. Without strict CSRF |
| 33 | +protections, malicious sites may be able to upload files on behalf of unsuspecting users. |
| 34 | + |
| 35 | +### Oversized or excess payloads |
| 36 | + |
| 37 | +Attackers can upload arbitrarily large files or extra files not referenced in the GraphQL operation. If your server accepts and |
| 38 | +buffers these files in memory, you may face reliability issues or be vulnerable to resource exhaustion. |
| 39 | + |
| 40 | +### Untrusted file metadata |
| 41 | + |
| 42 | +Uploaded file names, MIME types, and even contents are arbitrary and should be treated as untrusted input. Failing to sanitize |
| 43 | +file names can lead to path traversal vulnerabilities. Assuming a file’s MIME type is safe can lead to parsing exploits. |
| 44 | + |
| 45 | +## Recommendation: Use signed URLs |
49 | 46 |
|
50 | | -The server is responsible for parsing the multipart body, interpreting the `map`, and replacing |
51 | | -variable paths with the corresponding file streams. |
| 47 | +The most secure and scalable approach is to **avoid uploading files through GraphQL entirely**. Instead: |
52 | 48 |
|
53 | | -## Implementing uploads with graphql-http |
| 49 | +1. Use a GraphQL mutation to request a signed upload URL from your storage provider (e.g., Amazon S3). |
| 50 | +2. Upload the file directly from the client using that URL. |
| 51 | +3. Submit a second mutation to associate the uploaded file with your application’s data. |
54 | 52 |
|
55 | | -The `graphql-http` package doesn’t handle multipart requests out of the box. To support file |
56 | | -uploads, you’ll need to: |
| 53 | +This approach isolates the file upload concern to infrastructure purpose-built for it, while keeping GraphQL focused on structured data. |
57 | 54 |
|
58 | | -1. Parse the multipart form request. |
59 | | -2. Map the uploaded file(s) to GraphQL variables. |
60 | | -3. Inject those into the request body before passing it to `createHandler()`. |
| 55 | +## If you still choose to support uploads |
61 | 56 |
|
62 | | -Here's how to do it in an Express-based server using JavaScript and the [`busboy`](https://www.npmjs.com/package/busboy), |
63 | | -a popular library for parsing `multipart/form-data`. |
| 57 | +If your application truly requires file uploads through GraphQL, proceed with caution. At a minimum, you should: |
64 | 58 |
|
65 | | -### Example: Express + graphql-http + busboy |
| 59 | +- Use a well-maintained implementation of the |
| 60 | +[GraphQL multipart request spec](https://github.com/jaydenseric/graphql-multipart-request-spec). |
| 61 | +- Enforce a rule that upload variables are only referenced once. |
| 62 | +- Always stream uploads to disk or cloud storage—never buffer them in memory. |
| 63 | +- Apply strict request size limits and validate all fields. |
| 64 | +- Treat file names, types, and contents as untrusted data. |
| 65 | + |
| 66 | +## Example (not recommended for production) |
| 67 | + |
| 68 | +The example below demonstrates how uploads could be wired up using Express, `graphql-http`, and busboy. |
| 69 | +It’s included only to illustrate the mechanics and is not production-ready. |
| 70 | + |
| 71 | +<Callout type="warning" emoji="⚠️"> |
| 72 | + We strongly discourage using this code in production. |
| 73 | +</Callout> |
66 | 74 |
|
67 | 75 | ```js |
68 | 76 | import express from 'express'; |
@@ -110,74 +118,3 @@ app.post('/graphql', (req, res, next) => { |
110 | 118 |
|
111 | 119 | app.listen(4000); |
112 | 120 | ``` |
113 | | - |
114 | | -This example: |
115 | | - |
116 | | -- Parses `multipart/form-data` uploads. |
117 | | -- Extracts GraphQL query and variables from the `operations` field. |
118 | | -- Inserts file streams in place of `Upload` variables. |
119 | | -- Passes the modified request to `graphql-http`. |
120 | | - |
121 | | -## Defining the upload scalar |
122 | | - |
123 | | -The GraphQL schema must include a custom scalar type for uploaded files: |
124 | | - |
125 | | -```graphql |
126 | | -scalar Upload |
127 | | - |
128 | | -extend type Mutation { |
129 | | - uploadFile(file: Upload!): FileMetadata |
130 | | -} |
131 | | - |
132 | | -type FileMetadata { |
133 | | - filename: String! |
134 | | - mimetype: String! |
135 | | -} |
136 | | -``` |
137 | | - |
138 | | -In your resolvers, treat `file` as a readable stream: |
139 | | - |
140 | | -```js |
141 | | -export const resolvers = { |
142 | | - Upload: GraphQLScalarType, // implement as needed, or treat as opaque in resolver |
143 | | - Mutation: { |
144 | | - uploadFile: async (_, { file }) => { |
145 | | - const chunks = []; |
146 | | - for await (const chunk of file) { |
147 | | - chunks.push(chunk); |
148 | | - } |
149 | | - // process or store the file as needed |
150 | | - return { |
151 | | - filename: 'uploaded-file.txt', |
152 | | - mimetype: 'text/plain', |
153 | | - }; |
154 | | - } |
155 | | - } |
156 | | -}; |
157 | | -``` |
158 | | - |
159 | | -You can define `Upload` as a passthrough scalar if your server middleware already |
160 | | -handles file parsing: |
161 | | - |
162 | | -```js |
163 | | -import { GraphQLScalarType } from 'graphql'; |
164 | | - |
165 | | -export const Upload = new GraphQLScalarType({ |
166 | | - name: 'Upload', |
167 | | - serialize: () => { throw new Error('Upload serialization unsupported'); }, |
168 | | - parseValue: value => value, |
169 | | - parseLiteral: () => { throw new Error('Upload literals unsupported'); } |
170 | | -}); |
171 | | -``` |
172 | | - |
173 | | -## Best practices |
174 | | - |
175 | | -- Streaming: Don’t read entire files into memory. Instead, stream files to disk or an external |
176 | | -storage service. This reduces memory pressure and improves |
177 | | -scalability. |
178 | | -- Security: Always validate file types, restrict maximum file sizes, and sanitize filenames to prevent |
179 | | -path traversal or injection vulnerabilities. |
180 | | -- Alternatives: For large files or more scalable architectures, consider using pre-signed URLs |
181 | | -with an object storage service like S3. The client uploads the file directly, and the GraphQL |
182 | | -mutation receives the file URL instead. |
183 | | -- Client support: Use a client library that supports the GraphQL multipart request specification. |
0 commit comments