Skip to content

Commit 5a270ab

Browse files
author
Chris Funderburg
committed
fix(attachments): fixed attachment download functionality and path validation
1 parent 6fb546f commit 5a270ab

3 files changed

Lines changed: 82 additions & 13 deletions

File tree

server/src/index.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,14 @@ import searchRoutes from "./routes/search";
1111
import attachmentRoutes from "./routes/attachments";
1212
import { requireAuth } from "./middleware/auth";
1313
import { GitService } from "./services/gitService";
14-
import { FileSystemService } from "./services/fileSystem";
14+
import { DATA_DIR as DEFAULT_DATA_DIR, FileSystemService } from "./services/fileSystem";
1515

1616
const app: Express = express();
1717
const PORT = process.env.PORT || 3001;
1818

1919
// Initialize Git service
2020
const DATA_DIR =
21-
process.env.TEST_DATA_DIR || path.join(__dirname, "../../data");
21+
process.env.TEST_DATA_DIR || process.env.DATA_DIR || DEFAULT_DATA_DIR;
2222
export let gitService = new GitService(DATA_DIR);
2323
export let fileSystemService = new FileSystemService(DATA_DIR, gitService);
2424

@@ -238,7 +238,7 @@ if (process.env.NODE_ENV === "production") {
238238
app.use(express.static(clientDistPath));
239239

240240
// Handle client-side routing - serve index.html for all non-API routes
241-
app.get("*", (req: Request, res: Response) => {
241+
app.get(/.*/, (req: Request, res: Response) => {
242242
res.sendFile(path.join(clientDistPath, "index.html"));
243243
});
244244
}

server/src/routes/attachments.ts

Lines changed: 63 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,31 @@ import { Router, Request, Response } from "express";
22
import multer from "multer";
33
import path from "path";
44
import fs from "fs/promises";
5-
import { DATA_DIR } from "../services/fileSystem";
5+
import { DATA_DIR as DEFAULT_DATA_DIR } from "../services/fileSystem";
66
import { fileTransferLimiter, fileOperationLimiter } from "../middleware/rateLimiters";
77

88
const router = Router();
99

10+
const getDataDir = (): string => {
11+
return process.env.TEST_DATA_DIR || process.env.DATA_DIR || DEFAULT_DATA_DIR;
12+
};
13+
14+
const getFolderQueryParam = (req: Request): string => {
15+
const folderQuery = req.query.folder;
16+
if (typeof folderQuery === "string") {
17+
return folderQuery;
18+
}
19+
20+
// Fallback: parse raw URL so '+' is treated as space (x-www-form-urlencoded semantics)
21+
// This matches how browsers/axios tend to encode URL query parameters.
22+
try {
23+
const url = new URL(req.originalUrl, "http://localhost");
24+
return url.searchParams.get("folder") || "";
25+
} catch {
26+
return "";
27+
}
28+
};
29+
1030
/**
1131
* Validates and sanitizes a path to prevent path traversal attacks.
1232
* Ensures the resolved path is within the allowed base directory.
@@ -33,10 +53,12 @@ const validatePath = (basePath: string, ...userPath: string[]): string => {
3353
const storage = multer.diskStorage({
3454
destination: async (req, file, cb) => {
3555
try {
36-
const folderPath = (req.query.folder as string) || "";
56+
const folderPath = getFolderQueryParam(req);
57+
58+
const dataDir = getDataDir();
3759

3860
// Validate path to prevent traversal
39-
const attachmentsDir = validatePath(DATA_DIR, folderPath, ".attachments");
61+
const attachmentsDir = validatePath(dataDir, folderPath, ".attachments");
4062

4163
// Create .attachments directory if it doesn't exist
4264
await fs.mkdir(attachmentsDir, { recursive: true });
@@ -83,10 +105,12 @@ router.post("/", fileTransferLimiter, upload.single("file"), async (req: Request
83105
// List attachments for a folder
84106
router.get("/", async (req: Request, res: Response) => {
85107
try {
86-
const folderPath = (req.query.folder as string) || "";
108+
const folderPath = getFolderQueryParam(req);
109+
110+
const dataDir = getDataDir();
87111

88112
// Validate path to prevent traversal
89-
const attachmentsDir = validatePath(DATA_DIR, folderPath, ".attachments");
113+
const attachmentsDir = validatePath(dataDir, folderPath, ".attachments");
90114

91115
try {
92116
const files = await fs.readdir(attachmentsDir);
@@ -120,7 +144,7 @@ router.get("/", async (req: Request, res: Response) => {
120144
// Delete attachment
121145
router.delete("/:filename", fileOperationLimiter, async (req: Request, res: Response) => {
122146
try {
123-
const folderPath = (req.query.folder as string) || "";
147+
const folderPath = getFolderQueryParam(req);
124148
const filenameParam = req.params.filename;
125149

126150
if (!filenameParam || Array.isArray(filenameParam)) {
@@ -129,8 +153,10 @@ router.delete("/:filename", fileOperationLimiter, async (req: Request, res: Resp
129153

130154
const filename = filenameParam;
131155

156+
const dataDir = getDataDir();
157+
132158
// Validate path to prevent traversal - this validates both folderPath and filename
133-
const filePath = validatePath(DATA_DIR, folderPath, ".attachments", filename);
159+
const filePath = validatePath(dataDir, folderPath, ".attachments", filename);
134160

135161
await fs.unlink(filePath);
136162
res.json({ success: true });
@@ -149,7 +175,7 @@ router.delete("/:filename", fileOperationLimiter, async (req: Request, res: Resp
149175
// Get/download attachment
150176
router.get("/:filename", fileTransferLimiter, async (req: Request, res: Response) => {
151177
try {
152-
const folderPath = (req.query.folder as string) || "";
178+
const folderPath = getFolderQueryParam(req);
153179
const filenameParam = req.params.filename;
154180

155181
if (!filenameParam || Array.isArray(filenameParam)) {
@@ -158,12 +184,39 @@ router.get("/:filename", fileTransferLimiter, async (req: Request, res: Response
158184

159185
const filename = filenameParam;
160186

187+
const dataDir = getDataDir();
188+
161189
// Validate path to prevent traversal - this validates both folderPath and filename
162-
const filePath = validatePath(DATA_DIR, folderPath, ".attachments", filename);
190+
const filePath = validatePath(dataDir, folderPath, ".attachments", filename);
163191

164192
// nosemgrep: javascript.express.security.audit.express-res-sendfile.express-res-sendfile
165193
// Safe: filePath has been validated by validatePath() to prevent directory traversal
166-
res.sendFile(filePath);
194+
res.sendFile(filePath, { dotfiles: "allow" }, (error) => {
195+
if (!error) {
196+
return;
197+
}
198+
199+
// `send` defaults to ignoring dotfiles; our attachments live under `.attachments`.
200+
// Explicitly allow dotfiles above; remaining errors indicate missing/forbidden files.
201+
if ((error as any).code === "ENOENT" || (error as any).statusCode === 404) {
202+
if (!res.headersSent) {
203+
res.status(404).json({ error: "File not found" });
204+
}
205+
return;
206+
}
207+
208+
if ((error as any).code === "EACCES" || (error as any).statusCode === 403) {
209+
if (!res.headersSent) {
210+
res.status(403).json({ error: "Forbidden" });
211+
}
212+
return;
213+
}
214+
215+
console.error("Get attachment sendFile error:", error);
216+
if (!res.headersSent) {
217+
res.status(500).json({ error: "Failed to retrieve file" });
218+
}
219+
});
167220
} catch (error: any) {
168221
if (error.message === "Path traversal detected") {
169222
return res.status(403).json({ error: "Invalid file path" });

server/tests/api.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,22 @@ describe('API Tests', () => {
180180
});
181181
});
182182

183+
describe('Attachment Operations', () => {
184+
it('should download an attachment from a .attachments folder', async () => {
185+
const attachmentsDir = path.join(TEST_DATA_DIR, 'Howden', '.attachments');
186+
await fs.mkdir(attachmentsDir, { recursive: true });
187+
188+
const filename = 'test-attachment.txt';
189+
const filePath = path.join(attachmentsDir, filename);
190+
await fs.writeFile(filePath, 'hello attachment');
191+
192+
const response = await request(app).get(`/api/attachments/${filename}?folder=Howden`);
193+
194+
expect(response.status).toBe(200);
195+
expect(response.text).toBe('hello attachment');
196+
});
197+
});
198+
183199
describe('Health Check', () => {
184200
it('should return ok status', async () => {
185201
const response = await request(app).get('/api/health');

0 commit comments

Comments
 (0)