From 6d8fb9105d51e6cd6a8d93f60101ee5e4f35821f Mon Sep 17 00:00:00 2001 From: Brendan Chen Date: Tue, 3 Mar 2026 15:58:39 -0800 Subject: [PATCH] Code review fixes, Docker, and deployment config - Fix tsconfig: switch to ESNext/Bundler module resolution (tsx compatible) - Sanitize file extensions against path traversal (^.[a-zA-Z0-9]+$ only) - Sanitize Content-Disposition filename to prevent header injection - Extract tokenCookieOptions helper to eliminate duplication across auth handlers - Remove unused baseUrl param from fileListPage - Add Dockerfile (multi-stage build with alpine + native tools for bcrypt) - Add docker-compose.yml with named volume for data persistence - Add .env.example with all environment variables documented Co-Authored-By: Claude Sonnet 4.6 --- .env.example | 11 +++++++++++ Dockerfile | 34 ++++++++++++++++++++++++++++++++++ docker-compose.yml | 13 +++++++++++++ src/middleware/auth.ts | 9 +++++++++ src/routes/api/v1/auth.ts | 11 ++--------- src/routes/api/v1/files.ts | 3 ++- src/routes/pages.ts | 19 +++++++------------ src/views/file-list.ts | 2 +- tsconfig.json | 4 ++-- 9 files changed, 81 insertions(+), 25 deletions(-) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..fd7f705 --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +PORT=3000 +HOST=0.0.0.0 +JWT_SECRET=change-me-to-a-long-random-secret +JWT_EXPIRY=7d +DB_PATH=./data/nanodrop.db +UPLOAD_DIR=./data/uploads +LOG_FILE=./data/nanodrop.log +MAX_FILE_SIZE=104857600 +BASE_URL=http://localhost:3000 +COOKIE_SECURE=false +TRUST_PROXY=false diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7402ccd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,34 @@ +# Build stage +FROM node:22-alpine AS build + +# Install native build tools for bcrypt +RUN apk add --no-cache python3 make g++ + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci + +COPY tsconfig.json ./ +COPY src ./src + +RUN npm run build + +# Runtime stage +FROM node:22-alpine AS runtime + +RUN apk add --no-cache python3 make g++ + +WORKDIR /app + +COPY package*.json ./ +RUN npm ci --omit=dev + +COPY --from=build /app/dist ./dist +COPY public ./public + +RUN mkdir -p /app/data/uploads + +EXPOSE 3000 + +CMD ["node", "dist/index.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..62983a5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,13 @@ +services: + nanodrop: + build: . + ports: + - "3000:3000" + env_file: + - .env + volumes: + - nanodrop-data:/app/data + restart: unless-stopped + +volumes: + nanodrop-data: diff --git a/src/middleware/auth.ts b/src/middleware/auth.ts index d30614d..77050e5 100644 --- a/src/middleware/auth.ts +++ b/src/middleware/auth.ts @@ -13,3 +13,12 @@ export async function requireAuth(request: FastifyRequest, reply: FastifyReply): } } } + +export function tokenCookieOptions(secure: boolean): { + httpOnly: boolean; + sameSite: 'strict'; + secure: boolean; + path: string; +} { + return { httpOnly: true, sameSite: 'strict', secure, path: '/' }; +} diff --git a/src/routes/api/v1/auth.ts b/src/routes/api/v1/auth.ts index d2390b0..181bcc2 100644 --- a/src/routes/api/v1/auth.ts +++ b/src/routes/api/v1/auth.ts @@ -4,7 +4,7 @@ import type { Config } from '../../../config.ts'; import type { Logger } from '../../../middleware/logging.ts'; import { getUserByUsername } from '../../../db/users.ts'; import { verifyPassword } from '../../../services/auth.ts'; -import { requireAuth } from '../../../middleware/auth.ts'; +import { requireAuth, tokenCookieOptions } from '../../../middleware/auth.ts'; interface Deps { db: Database.Database; @@ -40,14 +40,7 @@ export const authApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { d await logger.authSuccess({ ip, userAgent, username }); const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); - reply - .setCookie('token', token, { - httpOnly: true, - sameSite: 'strict', - secure: config.cookieSecure, - path: '/', - }) - .send({ ok: true }); + reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).send({ ok: true }); }); app.post('/logout', { preHandler: requireAuth }, async (_request, reply) => { diff --git a/src/routes/api/v1/files.ts b/src/routes/api/v1/files.ts index 63c395b..4da58ce 100644 --- a/src/routes/api/v1/files.ts +++ b/src/routes/api/v1/files.ts @@ -34,7 +34,8 @@ export const filesApiRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { const fileBuffer = await data.toBuffer(); const id = nanoid(); - const ext = extname(data.filename); + const rawExt = extname(data.filename); + const ext = /^\.[a-zA-Z0-9]+$/.test(rawExt) ? rawExt : ''; const storedName = `${id}${ext}`; await saveFile(config.uploadDir, storedName, fileBuffer); diff --git a/src/routes/pages.ts b/src/routes/pages.ts index 2a435bd..b31df77 100644 --- a/src/routes/pages.ts +++ b/src/routes/pages.ts @@ -10,7 +10,7 @@ import { getUserByUsername } from '../db/users.ts'; import { createFile, getFileById, getFilesByUserId, deleteFile } from '../db/files.ts'; import { verifyPassword } from '../services/auth.ts'; import { saveFile, deleteStoredFile, getFilePath } from '../services/storage.ts'; -import { requireAuth } from '../middleware/auth.ts'; +import { requireAuth, tokenCookieOptions } from '../middleware/auth.ts'; import { loginPage } from '../views/login.ts'; import { uploadPage, uploadResultPage } from '../views/upload.ts'; import { fileListPage } from '../views/file-list.ts'; @@ -53,14 +53,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps await logger.authSuccess({ ip, userAgent, username }); const token = app.jwt.sign({ sub: user.id, username: user.username }, { expiresIn: config.jwtExpiry }); - reply - .setCookie('token', token, { - httpOnly: true, - sameSite: 'strict', - secure: config.cookieSecure, - path: '/', - }) - .redirect('/upload'); + reply.setCookie('token', token, tokenCookieOptions(config.cookieSecure)).redirect('/upload'); }); // POST /logout @@ -84,7 +77,8 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps const fileBuffer = await data.toBuffer(); const id = nanoid(); - const ext = extname(data.filename); + const rawExt = extname(data.filename); + const ext = /^\.[a-zA-Z0-9]+$/.test(rawExt) ? rawExt : ''; const storedName = `${id}${ext}`; await saveFile(config.uploadDir, storedName, fileBuffer); @@ -106,7 +100,7 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps app.get('/files', { preHandler: requireAuth }, async (request, reply) => { const { sub: userId } = request.user as JwtPayload; const files = getFilesByUserId(db, userId); - reply.type('text/html').send(fileListPage(files, config.baseUrl)); + reply.type('text/html').send(fileListPage(files)); }); // POST /files/:id/delete @@ -150,9 +144,10 @@ export const pageRoutes: FastifyPluginAsync<{ deps: Deps }> = async (app, { deps const filePath = getFilePath(config.uploadDir, file.stored_name); const data = await readFile(filePath); + const safeFilename = file.original_name.replace(/["\\\r\n]/g, '_'); reply .header('Content-Type', file.mime_type) - .header('Content-Disposition', `inline; filename="${file.original_name}"`) + .header('Content-Disposition', `inline; filename="${safeFilename}"`) .send(data); }); diff --git a/src/views/file-list.ts b/src/views/file-list.ts index 87576e0..e5d39ae 100644 --- a/src/views/file-list.ts +++ b/src/views/file-list.ts @@ -1,7 +1,7 @@ import { layout, escHtml } from './layout.ts'; import type { FileRow } from '../db/files.ts'; -export function fileListPage(files: FileRow[], baseUrl: string): string { +export function fileListPage(files: FileRow[]): string { const rows = files.length === 0 ? 'No files yet. Upload one.' : files.map((f) => ` diff --git a/tsconfig.json b/tsconfig.json index 565f886..17ac102 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { "target": "ES2022", - "module": "NodeNext", - "moduleResolution": "NodeNext", + "module": "ESNext", + "moduleResolution": "Bundler", "outDir": "dist", "rootDir": "src", "strict": true,