Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions challenges/birdsbirdbirds/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ export const DEFAULT_PORT = 4322
export const MAX_PNG_BYTES = 2 * 1024 * 1024
export const PORT_BUSY_EXIT_CODE = 3

/** How much of a refused upload the server reads and discards before it drops the connection. */
const MAX_DISCARDED_BYTES = 4 * MAX_PNG_BYTES

const LISTEN_HOST = '127.0.0.1'
const PAGE_DIRECTORY = path.join(path.dirname(fileURLToPath(import.meta.url)), 'page')
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
Expand Down Expand Up @@ -157,10 +160,21 @@ async function saveBird({ request, response, workspace, port }) {
send(response, 204)
}

/** Answers 413 and drops the connection once the answer is out, so the rest of the body is never read. */
/**
* Answers 413, then reads and discards the rest of the body, so the client
* can finish sending and read the answer. Closing on a client that is still
* sending can make it see a reset instead, unless it reads answers that
* arrive mid-upload as Chromium does. Drops the connection once more than
* MAX_DISCARDED_BYTES arrive, so an endless upload can't hold it open.
*/
function refuseOversizedBody(request, response) {
response.setHeader('Connection', 'close')
response.on('finish', () => request.destroy())
let discardedBytes = 0
request.on('data', (chunk) => {
discardedBytes += chunk.length
if (discardedBytes > MAX_DISCARDED_BYTES) request.destroy()
})
// readBodyWithin pauses the body when it gives up; a paused stream stays paused when a listener is added.
request.resume()
sendText(response, 413, `the image is over ${MAX_PNG_BYTES} bytes`)
}

Expand Down
56 changes: 53 additions & 3 deletions test/birdsbirdbirds.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -465,11 +465,41 @@ const sendRequest = ({ port, method = 'GET', requestPath = '/', headers = {}, bo
request.end(body)
})

/**
* Streams a chunked PNG upload over a raw socket until the server closes the
* connection or `maxBytes` have gone out, and resolves `{ status, dropped }`.
* `dropped` is true when the connection closed before the upload finished.
* A raw socket, since Node's HTTP client stops sending once it has a whole
* answer, and a browser doesn't.
*/
const streamUntilDropped = ({ port, maxBytes }) => new Promise((resolve) => {
const chunk = Buffer.alloc(64 * 1024)
const chunkFrame = Buffer.concat([Buffer.from(`${chunk.length.toString(16)}\r\n`), chunk, Buffer.from('\r\n')])
const socket = net.connect(port, '127.0.0.1')
let received = ''
let sentBytes = 0

/** Writes chunks until the socket pushes back or the cap is reached, then ends the body. */
const writeMore = () => {
while (sentBytes < maxBytes) {
sentBytes += chunk.length
if (!socket.write(chunkFrame)) return socket.once('drain', writeMore)
}
socket.end('0\r\n\r\n')
}

socket.setEncoding('latin1')
socket.on('data', (text) => { received += text })
socket.on('error', () => undefined)
socket.on('close', () => resolve({ status: Number(/^HTTP\/1\.1 (\d{3})/.exec(received)?.[1]), dropped: !socket.writableFinished }))
socket.write(`POST /bird.png HTTP/1.1\r\nHost: 127.0.0.1:${port}\r\nContent-Type: image/png\r\nTransfer-Encoding: chunked\r\n\r\n`)
writeMore()
})

/**
* POSTs only the headers of a PNG upload claiming `contentLength` bytes and
* resolves `{ status }`, then drops the request. The server refuses from
* Content-Length alone; a client still writing the body can get EPIPE before
* it reads the answer, as on macOS.
* resolves `{ status }`, then drops the request, since the server refuses
* from Content-Length alone.
*/
const postHeadersOnly = ({ port, contentLength }) => new Promise((resolve, reject) => {
const request = http.request({ host: '127.0.0.1', port, method: 'POST', path: '/bird.png', headers: { host: `127.0.0.1:${port}`, 'content-type': 'image/png', 'content-length': String(contentLength) } }, (response) => {
Expand Down Expand Up @@ -527,6 +557,26 @@ test('the server refuses a streamed body once it passes the cap, with no Content
assert.deepEqual(await readdir(workspace), [])
}))

test('the server lets an oversized upload finish sending and answers 413', (testContext) => withWorkspace(async ({ workspace }) => {
const port = await startBirdServer(testContext, workspace)

const upload = await streamUntilDropped({ port, maxBytes: 3 * MAX_PNG_BYTES })

assert.equal(upload.status, 413)
assert.equal(upload.dropped, false)
assert.deepEqual(await readdir(workspace), [])
}))

test('the server drops an oversized upload that keeps sending past the discard limit', (testContext) => withWorkspace(async ({ workspace }) => {
const port = await startBirdServer(testContext, workspace)

const upload = await streamUntilDropped({ port, maxBytes: 64 * MAX_PNG_BYTES })

assert.equal(upload.status, 413)
assert.equal(upload.dropped, true)
assert.deepEqual(await readdir(workspace), [])
}))

const REFUSED_UPLOADS = [
{ behavior: 'a body that is not a PNG with 415', upload: { body: Buffer.from('GIF89a not a png at all') }, status: 415 },
{ behavior: 'a PNG signature without an IHDR chunk with 415', upload: { body: Buffer.concat([PNG_SIGNATURE, pngChunk('IEND', Buffer.alloc(0))]) }, status: 415 },
Expand Down
Loading