Skip to content

Commit 8c0eaa1

Browse files
g0w6ykalenkevich
andauthored
fix: prevent path traversal in FileArtifactService (CWE-22) (#210)
* fix: move test to core/tests, import helpers from service * chore: revert package.json changes * chore: revert package-lock.json changes --------- Co-authored-by: g0w6y <g0w6y@users.noreply.github.com> Co-authored-by: Alexey Kalenkevich <kalenkevich@google.com>
1 parent b00cef7 commit 8c0eaa1

2 files changed

Lines changed: 127 additions & 4 deletions

File tree

core/src/artifacts/file_artifact_service.ts

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,35 @@ export class FileArtifactService implements BaseArtifactService {
393393
}
394394
}
395395

396-
function getUserRoot(rootDir: string, userId: string): string {
397-
return path.join(rootDir, 'users', userId);
396+
const SAFE_SEGMENT_RE = /^[a-zA-Z0-9_@-][a-zA-Z0-9_.@-]{0,255}$/;
397+
398+
export function assertSafeSegment(value: string, label: string): void {
399+
if (!value || !SAFE_SEGMENT_RE.test(value)) {
400+
throw new Error(
401+
`[FileArtifactService] Invalid ${label}: value contains disallowed characters.`,
402+
);
403+
}
404+
}
405+
406+
export function assertInsideRoot(
407+
resolvedPath: string,
408+
rootDir: string,
409+
label: string,
410+
): void {
411+
const root = path.resolve(rootDir);
412+
const resolved = path.resolve(resolvedPath);
413+
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
414+
throw new Error(
415+
`[FileArtifactService] ${label} escapes storage root. Resolved: ${resolved}, Root: ${root}`,
416+
);
417+
}
418+
}
419+
420+
export function getUserRoot(rootDir: string, userId: string): string {
421+
assertSafeSegment(userId, 'userId');
422+
const result = path.join(rootDir, 'users', userId);
423+
assertInsideRoot(result, rootDir, 'userRoot');
424+
return result;
398425
}
399426

400427
function isUserScoped(
@@ -408,8 +435,14 @@ function getUserArtifactsDir(userRoot: string): string {
408435
return path.join(userRoot, 'artifacts');
409436
}
410437

411-
function getSessionArtifactsDir(baseRoot: string, sessionId: string): string {
412-
return path.join(baseRoot, 'sessions', sessionId, 'artifacts');
438+
export function getSessionArtifactsDir(
439+
baseRoot: string,
440+
sessionId: string,
441+
): string {
442+
assertSafeSegment(sessionId, 'sessionId');
443+
const result = path.join(baseRoot, 'sessions', sessionId, 'artifacts');
444+
assertInsideRoot(result, baseRoot, 'sessionArtifactsDir');
445+
return result;
413446
}
414447

415448
function getVersionsDir(artifactDir: string): string {
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
/**
2+
* @license
3+
* Copyright 2026 Google LLC
4+
* SPDX-License-Identifier: Apache-2.0
5+
*/
6+
import {describe, expect, it} from 'vitest';
7+
import {
8+
assertInsideRoot,
9+
getSessionArtifactsDir,
10+
getUserRoot,
11+
} from '../src/artifacts/file_artifact_service.js';
12+
13+
const ROOT = '/tmp/adk-test-root';
14+
15+
describe('assertSafeSegment - valid inputs', () => {
16+
it('allows a plain alphanumeric userId', () => {
17+
expect(() => getUserRoot(ROOT, 'alice')).not.toThrow();
18+
});
19+
it('allows a UUID as userId', () => {
20+
expect(() =>
21+
getUserRoot(ROOT, '550e8400-e29b-41d4-a716-446655440000'),
22+
).not.toThrow();
23+
});
24+
it('allows an email-style userId', () => {
25+
expect(() => getUserRoot(ROOT, 'user.name@org')).not.toThrow();
26+
});
27+
it('allows a plain alphanumeric sessionId', () => {
28+
expect(() =>
29+
getSessionArtifactsDir(`${ROOT}/users/alice`, 'session-abc123'),
30+
).not.toThrow();
31+
});
32+
});
33+
34+
describe('assertSafeSegment - userId attacks', () => {
35+
it('blocks dot-dot-slash traversal in userId', () => {
36+
expect(() => getUserRoot(ROOT, '../../etc')).toThrow('Invalid userId');
37+
});
38+
it('blocks forward slash in userId', () => {
39+
expect(() => getUserRoot(ROOT, 'a/b')).toThrow('Invalid userId');
40+
});
41+
it('blocks percent-encoded slash in userId', () => {
42+
expect(() => getUserRoot(ROOT, '..%2F..%2Fetc')).toThrow('Invalid userId');
43+
});
44+
it('blocks null byte in userId', () => {
45+
expect(() => getUserRoot(ROOT, 'alice\x00')).toThrow('Invalid userId');
46+
});
47+
it('blocks empty string as userId', () => {
48+
expect(() => getUserRoot(ROOT, '')).toThrow('Invalid userId');
49+
});
50+
});
51+
52+
describe('assertSafeSegment - sessionId attacks', () => {
53+
const base = `${ROOT}/users/alice`;
54+
it('blocks dot-dot-slash traversal in sessionId', () => {
55+
expect(() => getSessionArtifactsDir(base, '../../../secret')).toThrow(
56+
'Invalid sessionId',
57+
);
58+
});
59+
it('blocks forward slash in sessionId', () => {
60+
expect(() => getSessionArtifactsDir(base, 'sess/../../etc')).toThrow(
61+
'Invalid sessionId',
62+
);
63+
});
64+
it('blocks percent-encoded slash in sessionId', () => {
65+
expect(() => getSessionArtifactsDir(base, '..%2F..%2F..%2Fsecret')).toThrow(
66+
'Invalid sessionId',
67+
);
68+
});
69+
it('blocks empty string as sessionId', () => {
70+
expect(() => getSessionArtifactsDir(base, '')).toThrow('Invalid sessionId');
71+
});
72+
});
73+
74+
describe('assertInsideRoot - defence-in-depth', () => {
75+
it('throws when resolved path escapes root', () => {
76+
expect(() =>
77+
assertInsideRoot('/tmp/root/../outside', '/tmp/root', 'test'),
78+
).toThrow('escapes storage root');
79+
});
80+
it('allows a path equal to root', () => {
81+
expect(() =>
82+
assertInsideRoot('/tmp/root', '/tmp/root', 'test'),
83+
).not.toThrow();
84+
});
85+
it('allows a path nested inside root', () => {
86+
expect(() =>
87+
assertInsideRoot('/tmp/root/users/alice', '/tmp/root', 'test'),
88+
).not.toThrow();
89+
});
90+
});

0 commit comments

Comments
 (0)