Skip to content

Commit e86e4a4

Browse files
committed
chore: security in-depth and a few inconsistencies between docs and manifest
1 parent e5e973d commit e86e4a4

5 files changed

Lines changed: 99 additions & 12 deletions

File tree

packages/appkit/src/plugins/files/helpers.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,30 @@ export {
22
contentTypeFromPath,
33
isTextContentType,
44
} from "../../connectors/files/defaults";
5+
6+
/**
7+
* Extract the parent directory from a file or directory path.
8+
*
9+
* Handles edge cases such as root-level files (`"/file.txt"` → `"/"`),
10+
* paths without slashes (`"file.txt"` → `""`), and trailing slashes.
11+
*/
12+
export function parentDirectory(path: string): string {
13+
const normalized =
14+
path.length > 1 && path.endsWith("/") ? path.slice(0, -1) : path;
15+
const lastSlash = normalized.lastIndexOf("/");
16+
17+
if (lastSlash > 0) return normalized.substring(0, lastSlash);
18+
if (normalized.startsWith("/")) return "/";
19+
return "";
20+
}
21+
22+
/**
23+
* Sanitize a filename for use in a `Content-Disposition` HTTP header.
24+
*
25+
* Redundancy check – Unity Catalog is unlikely to allow filenames with
26+
* quotes or control characters, but we sanitize defensively to prevent
27+
* HTTP header injection if upstream constraints ever change.
28+
*/
29+
export function sanitizeFilename(raw: string): string {
30+
return raw.replace(/["\\]/g, "\\$&").replace(/[\r\n]/g, "");
31+
}

packages/appkit/src/plugins/files/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
"permission": "WRITE_VOLUME",
1414
"fields": {
1515
"path": {
16-
"env": "DATABRICKS_VOLUME_PATH",
16+
"env": "DATABRICKS_DEFAULT_VOLUME",
1717
"description": "Volume path (e.g. /Volumes/catalog/schema/volume_name)"
1818
}
1919
}

packages/appkit/src/plugins/files/plugin.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
filesReadDefaults,
1515
filesWriteDefaults,
1616
} from "./defaults";
17+
import { parentDirectory, sanitizeFilename } from "./helpers";
1718
import { filesManifest } from "./manifest";
1819
import type { DownloadResponse, IFilesConfig } from "./types";
1920

@@ -340,7 +341,7 @@ export class FilesPlugin extends Plugin {
340341
return;
341342
}
342343

343-
const fileName = path.split("/").pop() ?? "download";
344+
const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
344345
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
345346
res.setHeader(
346347
"Content-Type",
@@ -392,7 +393,7 @@ export class FilesPlugin extends Plugin {
392393
res.setHeader("Content-Security-Policy", "sandbox");
393394

394395
if (!isSafeInlineContentType(resolvedType)) {
395-
const fileName = path.split("/").pop() ?? "download";
396+
const fileName = sanitizeFilename(path.split("/").pop() ?? "download");
396397
res.setHeader(
397398
"Content-Disposition",
398399
`attachment; filename="${fileName}"`,
@@ -525,8 +526,7 @@ export class FilesPlugin extends Plugin {
525526
return;
526527
}
527528

528-
const parentDir = path.substring(0, path.lastIndexOf("/")) || path;
529-
this._invalidateListCache(this._resolvePath(parentDir));
529+
this._invalidateListCache(this._resolvePath(parentDirectory(path)));
530530

531531
logger.debug(req, "Upload complete: path=%s", path);
532532
res.json(result);
@@ -558,8 +558,7 @@ export class FilesPlugin extends Plugin {
558558
return;
559559
}
560560

561-
const parentDir = dirPath.substring(0, dirPath.lastIndexOf("/")) || dirPath;
562-
this._invalidateListCache(this._resolvePath(parentDir));
561+
this._invalidateListCache(this._resolvePath(parentDirectory(dirPath)));
563562

564563
res.json(result);
565564
}
@@ -588,8 +587,7 @@ export class FilesPlugin extends Plugin {
588587
return;
589588
}
590589

591-
const parentDir = path.substring(0, path.lastIndexOf("/")) || path;
592-
this._invalidateListCache(this._resolvePath(parentDir));
590+
this._invalidateListCache(this._resolvePath(parentDirectory(path)));
593591

594592
res.json(result);
595593
}

packages/appkit/src/plugins/files/tests/helpers.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { describe, expect, test } from "vitest";
2-
import { contentTypeFromPath, isTextContentType } from "../helpers";
2+
import {
3+
contentTypeFromPath,
4+
isTextContentType,
5+
parentDirectory,
6+
sanitizeFilename,
7+
} from "../helpers";
38

49
describe("contentTypeFromPath", () => {
510
test("works without reported type", () => {
@@ -119,3 +124,60 @@ describe("isTextContentType", () => {
119124
expect(isTextContentType("image/jpeg")).toBe(false);
120125
});
121126
});
127+
128+
describe("parentDirectory", () => {
129+
test("extracts parent from nested path", () => {
130+
expect(parentDirectory("/Volumes/catalog/schema/vol/file.txt")).toBe(
131+
"/Volumes/catalog/schema/vol",
132+
);
133+
});
134+
135+
test("extracts parent from two-segment path", () => {
136+
expect(parentDirectory("/dir/file.txt")).toBe("/dir");
137+
});
138+
139+
test("returns root for root-level file", () => {
140+
expect(parentDirectory("/file.txt")).toBe("/");
141+
});
142+
143+
test("returns empty string for relative path without slash", () => {
144+
expect(parentDirectory("file.txt")).toBe("");
145+
});
146+
147+
test("strips trailing slash before computing parent", () => {
148+
expect(parentDirectory("/dir/subdir/")).toBe("/dir");
149+
});
150+
151+
test("handles root path with trailing slash", () => {
152+
expect(parentDirectory("/")).toBe("/");
153+
});
154+
155+
test("handles relative nested path", () => {
156+
expect(parentDirectory("subdir/file.txt")).toBe("subdir");
157+
});
158+
});
159+
160+
describe("sanitizeFilename", () => {
161+
test("passes through clean filenames unchanged", () => {
162+
expect(sanitizeFilename("report.pdf")).toBe("report.pdf");
163+
expect(sanitizeFilename("my-file_v2.txt")).toBe("my-file_v2.txt");
164+
});
165+
166+
test("escapes double quotes", () => {
167+
expect(sanitizeFilename('file"name.txt')).toBe('file\\"name.txt');
168+
});
169+
170+
test("escapes backslashes", () => {
171+
expect(sanitizeFilename("file\\name.txt")).toBe("file\\\\name.txt");
172+
});
173+
174+
test("strips carriage returns and newlines", () => {
175+
expect(sanitizeFilename("file\r\nname.txt")).toBe("filename.txt");
176+
expect(sanitizeFilename("file\rname.txt")).toBe("filename.txt");
177+
expect(sanitizeFilename("file\nname.txt")).toBe("filename.txt");
178+
});
179+
180+
test("handles combined special characters", () => {
181+
expect(sanitizeFilename('a"b\\c\r\nd.txt')).toBe('a\\"b\\\\cd.txt');
182+
});
183+
});

packages/appkit/src/plugins/files/tests/plugin.integration.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ describe("Files Plugin Integration", () => {
7070

7171
beforeAll(async () => {
7272
setupDatabricksEnv({
73-
DATABRICKS_VOLUME_PATH: "/Volumes/catalog/schema/vol",
73+
DATABRICKS_DEFAULT_VOLUME: "/Volumes/catalog/schema/vol",
7474
});
7575
ServiceContext.reset();
7676

@@ -96,7 +96,7 @@ describe("Files Plugin Integration", () => {
9696
});
9797

9898
afterAll(async () => {
99-
delete process.env.DATABRICKS_VOLUME_PATH;
99+
delete process.env.DATABRICKS_DEFAULT_VOLUME;
100100
serviceContextMock?.restore();
101101
if (server) {
102102
await new Promise<void>((resolve, reject) => {

0 commit comments

Comments
 (0)