diff --git a/lib/cli/storage-s3/index.js b/lib/cli/storage-s3/index.js index 8554b37484..0f8e6ef9b2 100644 --- a/lib/cli/storage-s3/index.js +++ b/lib/cli/storage-s3/index.js @@ -31,6 +31,15 @@ export const builder = function(yargs) { type: 'string', demand: true }) + .option('secret', { + alias: 's', + describe: 'The secret to use for auth JSON Web Tokens. Anyone who ' + + 'knows this token can freely enter the system if they want, so keep ' + + 'it safe.', + type: 'string', + default: process.env.SECRET, + demand: true + }) .epilog('Each option can be be overwritten with an environment variable ' + 'by converting the option to uppercase, replacing dashes with ' + 'underscores and prefixing it with `STF_STORAGE_S3_` (e.g. ' + @@ -42,6 +51,7 @@ export const handler = function(argv) { profile: argv.profile, bucket: argv.bucket, endpoint: argv.endpoint, - maxFileSize: argv.maxFileSize + maxFileSize: argv.maxFileSize, + secret: argv.secret }) } diff --git a/lib/units/storage/s3.ts b/lib/units/storage/s3.ts index bbe81dd73b..c9be1301cb 100644 --- a/lib/units/storage/s3.ts +++ b/lib/units/storage/s3.ts @@ -10,6 +10,8 @@ import { fromIni } from '@aws-sdk/credential-providers' import { S3 } from '@aws-sdk/client-s3' import logger from '../../util/logger.js' import rateLimitConfig from '../ratelimit/index.js' +import { accessTokenAuth } from '../api/helpers/securityHandlers.js' +import db from '../../db/index.js' // @ts-ignore import formidable from 'formidable' @@ -20,6 +22,7 @@ interface S3Options { bucket: string maxFileSize: number port: number + secret: string } interface UploadedFile { @@ -37,24 +40,28 @@ interface ResourceResponse { href: string } -export default function (options: S3Options) { +export const createApp = (options: S3Options, s3: S3) => { const log = logger.createLogger('storage:s3') const app = express() - const server = http.createServer(app) - const s3 = new S3({ - // JS SDK v3 switched credential providers from classes to functions. - // This is the closest approximation from codemod of what your application needs. - // Reference: https://www.npmjs.com/package/@aws-sdk/credential-providers - credentials: fromIni({ - profile: options.profile - }), - endpoint: options.endpoint - }) app.set('strict routing', true) app.set('case sensitive routing', true) app.set('trust proxy', true) app.use(rateLimitConfig) + + // Same check as storage-temp: every file belongs to a logged in user + app.use(async (req: any, res, next) => { + req.options = { + secret: options.secret + } + try { + await accessTokenAuth(req) + next() + } catch (err: any) { + res.status(err.status || 500).json({ message: err.message }) + } + }) + app.use(bodyParser.json()) app.use(validator()) @@ -161,6 +168,24 @@ export default function (options: S3Options) { } }) + return app +} + +export default async function (options: S3Options) { + const log = logger.createLogger('storage:s3') + await db.connect() + + const s3 = new S3({ + // JS SDK v3 switched credential providers from classes to functions. + // This is the closest approximation from codemod of what your application needs. + // Reference: https://www.npmjs.com/package/@aws-sdk/credential-providers + credentials: fromIni({ + profile: options.profile + }), + endpoint: options.endpoint + }) + + const server = http.createServer(createApp(options, s3)) server.listen(options.port) log.info('Listening on port %s', options.port) } diff --git a/test/unit/units/storage/s3.test.ts b/test/unit/units/storage/s3.test.ts new file mode 100644 index 0000000000..52472e5fa5 --- /dev/null +++ b/test/unit/units/storage/s3.test.ts @@ -0,0 +1,67 @@ +import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest' +import type {AddressInfo} from 'node:net' +import type {Server} from 'node:http' + +vi.mock('../../../../lib/db/api.js', () => ({ + default: { + loadUser: vi.fn(), + loadAccessTokenById: vi.fn() + } +})) + +import dbapi from '../../../../lib/db/api.js' +import * as jwtutil from '../../../../lib/util/jwtutil.js' +import {createApp} from '../../../../lib/units/storage/s3.js' + +const secret = 'secret' +const user = {email: 'user@example.com', name: 'User', privilege: 'user'} + +describe('S3 storage unit', () => { + let server: Server + let base: string + const s3 = {getObject: vi.fn(), putObject: vi.fn()} + + beforeEach(async() => { + vi.mocked(dbapi.loadUser).mockResolvedValue(user) + s3.getObject.mockReset().mockResolvedValue({ContentType: 'text/plain', Body: 'content'}) + s3.putObject.mockReset().mockResolvedValue({}) + + const app = createApp({ + profile: 'default', + bucket: 'bucket', + maxFileSize: 1024, + port: 0, + secret + }, s3 as any) + server = app.listen(0) + await new Promise(resolve => server.once('listening', resolve)) + base = `http://127.0.0.1:${(server.address() as AddressInfo).port}` + }) + + afterEach(() => new Promise(resolve => server.close(() => resolve()))) + + it('does not hand out a file without a token', async() => { + const res = await fetch(`${base}/s/blob/id/name`) + + expect(res.status).toBe(401) + expect(s3.getObject).not.toHaveBeenCalled() + }) + + it('does not store a file without a token', async() => { + const form = new FormData() + form.append('file', new Blob(['content']), 'file.txt') + const res = await fetch(`${base}/s/upload/blob`, {method: 'POST', body: form}) + + expect(res.status).toBe(401) + expect(s3.putObject).not.toHaveBeenCalled() + }) + + it('hands out a file to a logged in user', async() => { + const jwt = jwtutil.encode({payload: {email: user.email, name: user.name}, secret}) + const res = await fetch(`${base}/s/blob/id/name`, {headers: {Authorization: `Bearer ${jwt}`}}) + + expect(res.status).toBe(200) + expect(await res.text()).toBe('content') + expect(s3.getObject).toHaveBeenCalledWith({Key: 'id', Bucket: 'bucket'}) + }) +})