fix(session-manager): apply outbox path-confinement to inbound attachments
Mirrors the four defenses on the outbound side onto extractAttachmentFiles:
1. Reject unsafe messageId via isSafeAttachmentName before any inbox path
is built. WhatsApp passes msg.key.id through raw and that field is
client generated, so a peer can craft it; future end to end encrypted
adapters will have the same property.
2. lstatSync on the inbox dir refuses a pre placed symlink before
mkdirSync would silently follow it.
3. realpathSync + isPathInside contains the resolved dir under the
session inbox root.
4. writeFileSync uses the wx flag so a pre placed symlink at the file
path is refused atomically by the kernel; EEXIST surfaces as a
logged skip.
Threat: the session dir is mounted writable into the container at
/workspace, so a compromised agent can pre place inbox/<future msgId>/
as a symlink and wait for a chat message with a matching id to redirect
the host write. The four guards together close that window.
Consolidates with the existing isSafeAttachmentName helper from
attachment-safety.ts rather than introducing a duplicate basename
validator inside session-manager.
Co-Authored-By: Daisuke Tsuji <dim0627@gmail.com>
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -162,6 +162,95 @@ describe('session manager', () => {
|
||||
expect(fs.existsSync(msgOutbox)).toBe(false);
|
||||
});
|
||||
|
||||
it('should reject inbound attachment writes through a pre-placed symlinked inbox dir', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// The container has /workspace write access, so it can pre create
|
||||
// inbox/<msgId> as a symlink to escape.
|
||||
const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
fs.mkdirSync(inboxRoot, { recursive: true });
|
||||
const evilTarget = path.join(TEST_DIR, 'evil-target');
|
||||
fs.mkdirSync(evilTarget, { recursive: true });
|
||||
fs.symlinkSync(evilTarget, path.join(inboxRoot, 'msg-evil'));
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-evil',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'evil',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(evilTarget, 'photo.png'))).toBe(false);
|
||||
});
|
||||
|
||||
it('should refuse to follow a pre-existing symlink at the inbound attachment path', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
// The container pre creates inbox/<msgId>/photo.png as a symlink to a
|
||||
// host file. Without the wx flag, writeFileSync would follow it.
|
||||
const inboxDir = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-sym');
|
||||
fs.mkdirSync(inboxDir, { recursive: true });
|
||||
const outside = path.join(TEST_DIR, 'outside.txt');
|
||||
fs.writeFileSync(outside, 'ORIGINAL');
|
||||
fs.symlinkSync(outside, path.join(inboxDir, 'photo.png'));
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-sym',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'sym',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(fs.readFileSync(outside, 'utf-8')).toBe('ORIGINAL');
|
||||
});
|
||||
|
||||
it('should reject inbound attachments when messageId is unsafe', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: '../../escape',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'msgid',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const inboxRoot = path.join(sessionDir('ag-1', session.id), 'inbox');
|
||||
if (fs.existsSync(inboxRoot)) {
|
||||
expect(fs.readdirSync(inboxRoot)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
it('should still save inbound attachments with safe basenames', () => {
|
||||
initSessionFolder('ag-1', 'sess-test');
|
||||
const { session } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
|
||||
writeSessionMessage('ag-1', session.id, {
|
||||
id: 'msg-ok',
|
||||
kind: 'chat',
|
||||
timestamp: now(),
|
||||
content: JSON.stringify({
|
||||
text: 'ok',
|
||||
attachments: [{ name: 'photo.png', data: Buffer.from('PNGBYTES').toString('base64'), size: 8 }],
|
||||
}),
|
||||
});
|
||||
|
||||
const expected = path.join(sessionDir('ag-1', session.id), 'inbox', 'msg-ok', 'photo.png');
|
||||
expect(fs.existsSync(expected)).toBe(true);
|
||||
expect(fs.readFileSync(expected, 'utf-8')).toBe('PNGBYTES');
|
||||
});
|
||||
|
||||
it('should resolve to existing session (shared mode)', () => {
|
||||
const { session: s1, created: c1 } = resolveSession('ag-1', 'mg-1', null, 'shared');
|
||||
expect(c1).toBe(true);
|
||||
|
||||
Reference in New Issue
Block a user