mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
fix(collab): close the image upload hole and the guard trap
The chat image filter believed the Content-Type the client put on the part. The stored name keeps the extension of the name the client sent, and the download route derives what it serves from that extension and sends it inline, so pwn.html declared as image/png came back as HTML on our own origin. The filter now checks the extension as well, against the blocked list and against the types it claims to accept, because a per-route filter fully replaces the module-level one it looked like it was adding to. The multipart message route also kept its guard decorators. Guards run before the interceptor, so a refusal went out while the client was still streaming up to four images and the socket died as ECONNRESET rather than carrying the error. The checks moved into the handler, where the other upload route in this file already does them, and a test pins both refusals. The three collab:link events were missing from the shared registry, which the server typecheck refuses, and the admin Links switch could never be saved because the Zod contract had no key for it. Both are in now. Migrations moved to the end of the list and the column add uses the pragma guard rather than a caught duplicate-column error. The message-required string keeps its old wording for a request without a file part, so a client from before this route took images sees what it always saw.
This commit is contained in:
@@ -78,7 +78,7 @@ describe('AddonsService.list', () => {
|
||||
feedReads([], [], [], [{ key: 'collab_chat_enabled', value: 'false' }], { value: 'true' });
|
||||
|
||||
const res = svc().list();
|
||||
expect(res.collabFeatures).toEqual({ chat: false, notes: true, polls: true, whatsnext: true });
|
||||
expect(res.collabFeatures).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
expect(res.bagTracking).toBe(true);
|
||||
expect(res.addons).toEqual([]);
|
||||
});
|
||||
@@ -311,7 +311,7 @@ describe('AddonsService addon/feature flags', () => {
|
||||
{ key: 'collab_chat_enabled', value: 'false' },
|
||||
{ key: 'collab_polls_enabled', value: 'true' },
|
||||
]);
|
||||
expect(svc().getCollabFeatures()).toEqual({ chat: false, notes: true, polls: true, whatsnext: true });
|
||||
expect(svc().getCollabFeatures()).toEqual({ chat: false, notes: true, links: true, polls: true, whatsnext: true });
|
||||
});
|
||||
|
||||
it('updateCollabFeatures writes only the provided flags and reports changed (#1414, ADMIN-SVC-070)', () => {
|
||||
|
||||
@@ -154,17 +154,33 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
});
|
||||
|
||||
describe('messages', () => {
|
||||
it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', () => {
|
||||
expect(thrown(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }))).toEqual({ status: 400, body: { error: 'Message text is required' } });
|
||||
expect(thrown(() => new CollabController(svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial<CollabService>), storageStub).createMessage(user, '5', { text: 'hi', reply_to: 99 }))).toEqual({ status: 400, body: { error: 'Reply target message not found' } });
|
||||
// Async since the route took its multipart form: it awaits the storage
|
||||
// commit, so every case here goes through thrownAsync.
|
||||
it('POST 400 whitespace-only, 400 reply_not_found, else creates + notifies (length checks now in the Zod pipe)', async () => {
|
||||
// A request with no file part keeps the wording it always had.
|
||||
expect(await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, undefined)))
|
||||
.toEqual({ status: 400, body: { error: 'Message text is required' } });
|
||||
expect(await thrownAsync(() => new CollabController(svc(), storageStub).createMessage(user, '5', { text: ' ' }, [])))
|
||||
.toEqual({ status: 400, body: { error: 'Message text or image is required' } });
|
||||
expect(await thrownAsync(() => new CollabController(svc({ createMessage: vi.fn().mockReturnValue({ error: 'reply_not_found' }) } as Partial<CollabService>), storageStub).createMessage(user, '5', { text: 'hi', reply_to: 99 }, undefined)))
|
||||
.toEqual({ status: 400, body: { error: 'Reply target message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
const notifyCollab = vi.fn();
|
||||
const s = svc({ createMessage: vi.fn().mockReturnValue({ message: { id: 3 } }), broadcast, notifyCollab } as Partial<CollabService>);
|
||||
expect(new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, 'sock')).toEqual({ message: { id: 3 } });
|
||||
expect(await new CollabController(s, storageStub).createMessage(user, '5', { text: 'hello' }, undefined, 'sock')).toEqual({ message: { id: 3 } });
|
||||
expect(broadcast).toHaveBeenCalledWith('5', 'collab:message:created', { message: { id: 3 } }, 'sock');
|
||||
expect(notifyCollab).toHaveBeenCalledWith('5', user, 'hello');
|
||||
});
|
||||
|
||||
it('refuses a caller who cannot reach the trip or cannot write, from inside the handler', async () => {
|
||||
// The decorators had to go because of the multipart body, so these two
|
||||
// refusals are the only thing standing in front of the route now.
|
||||
expect(await thrownAsync(() => new CollabController(svc({ verifyTripAccess: vi.fn().mockReturnValue(null) } as Partial<CollabService>), storageStub).createMessage(user, '5', { text: 'hi' }, undefined)))
|
||||
.toEqual({ status: 404, body: { error: 'Trip not found' } });
|
||||
expect(await thrownAsync(() => new CollabController(svc({ canEdit: vi.fn().mockReturnValue(false) } as Partial<CollabService>), storageStub).createMessage(user, '5', { text: 'hi' }, undefined)))
|
||||
.toEqual({ status: 403, body: { error: 'No permission' } });
|
||||
});
|
||||
|
||||
it('react 404 unknown, else broadcasts reactions (empty emoji now 400s in the Zod pipe)', () => {
|
||||
expect(thrown(() => new CollabController(svc({ reactMessage: vi.fn().mockReturnValue({ found: false, reactions: [] }) } as Partial<CollabService>), storageStub).react(user, '5', '3', { emoji: '👍' }))).toEqual({ status: 404, body: { error: 'Message not found' } });
|
||||
const broadcast = vi.fn();
|
||||
@@ -205,7 +221,19 @@ describe('CollabController (parity with the legacy /api/trips/:tripId/collab rou
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
||||
}
|
||||
// The write siblings do demand it, so this is a deliberate split, not an omission.
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, CollabController.prototype.createMessage)).toBe('collab_edit');
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, CollabController.prototype.createNote)).toBe('collab_edit');
|
||||
});
|
||||
|
||||
it('the multipart write routes carry no guard decorators, and check inside instead', () => {
|
||||
// Guards run before the interceptor, so a refusal goes out while the client
|
||||
// is still streaming the body and the socket dies as ECONNRESET rather than
|
||||
// carrying the error envelope. Both of these therefore look unguarded here
|
||||
// and do requireTrip plus the permission check in the handler; the test
|
||||
// below proves createMessage actually refuses.
|
||||
for (const handler of [CollabController.prototype.createMessage, CollabController.prototype.addNoteFile]) {
|
||||
expect(Reflect.getMetadata(TRIP_PERMISSION_KEY, handler)).toBeUndefined();
|
||||
expect(guardsOn(handler)).not.toContain(TripAccessGuard);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user