Skip to content
Closed
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
55 changes: 53 additions & 2 deletions routes/fileServer.ts
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,63 @@
import * as security from '../lib/insecurity'
import { challenges } from '../data/datacache'
import * as challengeUtils from '../lib/challengeUtils'
import { ordersCollection } from '../data/mongodb'

const cookieToken = (req: Request): string | undefined => {
if (req.cookies?.token) return req.cookies.token
const raw = req.headers.cookie?.split(';').map(cookie => cookie.trim()).find(cookie => cookie.startsWith('token='))?.slice('token='.length)
if (!raw) return undefined
try {
return decodeURIComponent(raw)
} catch {
return raw
}
}

const orderOwnerEmail = (req: Request): string | undefined => {
for (const token of [cookieToken(req), utils.jwtFrom(req)]) {
try {
if (token && security.verify(token)) {
const email = security.decode(token)?.data?.email
if (email) return email
}
} catch {
continue
}
}
return undefined
}

export function servePublicFiles () {
return ({ params, query }: Request, res: Response, next: NextFunction) => {
const file = params.file
return async (req: Request, res: Response, next: NextFunction) => {
const file = req.params.file

if (!file.includes('/')) {
const effectiveFile = security.cutOffPoisonNullByte(file)
if (effectiveFile.startsWith('order_')) {
const email = orderOwnerEmail(req)
if (!email) {
res.status(401)
next(new Error('Order confirmations can only be downloaded by logged-in customers!'))
return
}
const orderId = effectiveFile.slice('order_'.length).replace(/\.pdf$/i, '')
let order
try {
order = await ordersCollection.findOne({ orderId })
} catch (error: unknown) {
next(error instanceof Error ? error : new Error(String(error)))
return
}
const ownsOrder = order != null &&
order.email === email.replace(/[aeiou]/gi, '*') &&
orderId.startsWith(security.hash(email).slice(0, 4) + '-')
if (!ownsOrder) {
res.status(403)
next(new Error('Order confirmations can only be downloaded by the customer who placed the order!'))
return
}
}
verify(file, res, next)
} else {
res.status(403)
Expand All @@ -24,13 +75,13 @@
}

function verify (file: string, res: Response, next: NextFunction) {
if (file && (endsWithAllowlistedFileType(file) || (file === 'incident-support.kdbx'))) {

Check failure

Code scanning / CodeQL

User-controlled bypass of security check High

This condition guards a sensitive
action
, but a
user-provided value
controls it.
file = security.cutOffPoisonNullByte(file)

challengeUtils.solveIf(challenges.directoryListingChallenge, () => { return file.toLowerCase() === 'acquisitions.md' })
verifySuccessfulPoisonNullByteExploit(file)

res.sendFile(path.resolve('ftp/', file))

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
} else {
res.status(403)
next(new Error('Only .md and .pdf files are allowed!'))
Expand Down
15 changes: 15 additions & 0 deletions test/api/ftp-folder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import assert from 'node:assert/strict'
import request from 'supertest'
import type { Express } from 'express'
import { createTestApp } from './helpers/setup'
import * as security from '../../lib/insecurity'

let app: Express

Expand Down Expand Up @@ -41,6 +42,20 @@ void describe('/ftp', () => {
assert.equal(res.status, 404)
})

void it('GET an order confirmation PDF anonymously will return 401', async () => {
const res = await request(app)
.get('/ftp/order_1234-0123456789abcdef.pdf')
assert.equal(res.status, 401)
})

void it('GET a non-existing order confirmation PDF with a valid token will return 403', async () => {
const token = security.authorize({ data: { email: 'jim@juice-sh.op' } })
const res = await request(app)
.get('/ftp/order_1234-0123456789abcdef.pdf')
.set('Cookie', 'token=' + token)
assert.equal(res.status, 403)
})

void it('GET a non-existing file in /ftp will return a 403 error for invalid file type', async () => {
const res = await request(app)
.get('/ftp/doesnotexist.exe')
Expand Down
96 changes: 95 additions & 1 deletion test/server/fileServerSpec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import chai from 'chai'
import sinonChai from 'sinon-chai'
import { challenges } from '../../data/datacache'
import { servePublicFiles } from '../../routes/fileServer'
import * as security from '../../lib/insecurity'
import * as mongodb from '../../data/mongodb'
import { type Challenge } from 'data/types'
const expect = chai.expect
chai.use(sinonChai)
Expand All @@ -20,13 +22,17 @@ describe('fileServer', () => {

beforeEach(() => {
res = { sendFile: sinon.spy(), status: sinon.spy() }
req = { params: {}, query: {} }
req = { params: {}, query: {}, headers: {}, cookies: {} }
next = sinon.spy()
save = () => ({
then () { }
})
})

afterEach(() => {
sinon.restore()
})

it('should serve PDF files from folder /ftp', () => {
req.params.file = 'test.pdf'

Expand All @@ -35,6 +41,94 @@ describe('fileServer', () => {
expect(res.sendFile).to.have.been.calledWith(sinon.match(/ftp[/\\]test\.pdf/))
})

it('should deny order confirmation PDFs to anonymous requests', async () => {
req.params.file = 'order_1234-0123456789abcdef.pdf'

await servePublicFiles()(req, res, next)

expect(res.status).to.have.been.calledWith(401)
expect(res.sendFile).to.have.not.been.calledWith(sinon.match.any)
expect(next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})

it('should deny order confirmation PDFs of other customers', async () => {
const token = security.authorize({ data: { email: 'a@juice-sh.op' } })
req.headers.cookie = 'token=' + token
req.params.file = 'order_1234-0123456789abcdef.pdf'
sinon.stub(mongodb.ordersCollection, 'findOne').resolves({ orderId: '1234-0123456789abcdef', email: 'b@j**c*-sh.*p' })

await servePublicFiles()(req, res, next)

expect(res.status).to.have.been.calledWith(403)
expect(res.sendFile).to.have.not.been.calledWith(sinon.match.any)
expect(next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})

it('should deny order confirmation PDFs for an unknown order', async () => {
const token = security.authorize({ data: { email: 'a@juice-sh.op' } })
req.headers.cookie = 'token=' + token
req.params.file = 'order_1234-0123456789abcdef.pdf'
sinon.stub(mongodb.ordersCollection, 'findOne').resolves(null)

await servePublicFiles()(req, res, next)

expect(res.status).to.have.been.calledWith(403)
expect(res.sendFile).to.have.not.been.calledWith(sinon.match.any)
expect(next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})

it('should deny order confirmation PDFs with a matching email but a different hash prefix', async () => {
const email = 'a@juice-sh.op'
const token = security.authorize({ data: { email } })
req.headers.cookie = 'token=' + token
req.params.file = 'order_zzzz-0123456789abcdef.pdf'
sinon.stub(mongodb.ordersCollection, 'findOne').resolves({ orderId: 'zzzz-0123456789abcdef', email: '*@j**c*-sh.*p' })

await servePublicFiles()(req, res, next)

expect(res.status).to.have.been.calledWith(403)
expect(res.sendFile).to.have.not.been.calledWith(sinon.match.any)
expect(next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})

it('should use a valid bearer token when the cookie token is invalid', async () => {
const email = 'a@juice-sh.op'
const token = security.authorize({ data: { email } })
const orderId = security.hash(email).slice(0, 4) + '-0123456789abcdef'
req.headers.cookie = 'token=invalid-token'
req.headers.authorization = 'Bearer ' + token
req.params.file = 'order_' + orderId + '.pdf'
sinon.stub(mongodb.ordersCollection, 'findOne').resolves({ orderId, email: '*@j**c*-sh.*p' })

await servePublicFiles()(req, res, next)

expect(res.sendFile).to.have.been.calledWith(sinon.match(/ftp[/\\]order_/))
})

it('should deny malformed cookie tokens without throwing', async () => {
req.headers.cookie = 'token=%E0%A4%A'
req.params.file = 'order_1234-0123456789abcdef.pdf'

await servePublicFiles()(req, res, next)

expect(res.status).to.have.been.calledWith(401)
expect(res.sendFile).to.have.not.been.calledWith(sinon.match.any)
expect(next).to.have.been.calledWith(sinon.match.instanceOf(Error))
})

it('should serve order confirmation PDFs to the customer who placed the order', async () => {
const email = 'a@juice-sh.op'
const token = security.authorize({ data: { email } })
const orderId = security.hash(email).slice(0, 4) + '-0123456789abcdef'
req.headers.cookie = 'token=' + token
req.params.file = 'order_' + orderId + '.pdf'
sinon.stub(mongodb.ordersCollection, 'findOne').resolves({ orderId, email: '*@j**c*-sh.*p' })

await servePublicFiles()(req, res, next)

expect(res.sendFile).to.have.been.calledWith(sinon.match(/ftp[/\\]order_/))
})

it('should serve Markdown files from folder /ftp', () => {
req.params.file = 'test.md'

Expand Down
Loading