Compare commits

...
Author SHA1 Message Date
Laurent Cozic c8efb34425 Chore: Fix test warnings 2026-11-11 18:32:34 +00:00
Laurent Cozic 9c2a9c6d7d Chore: Fix flaky ipc test caused by random ports in the ephemeral range 2026-11-11 18:28:59 +00:00
Laurent Cozic 34696bc462 Server: Reduce database load of the orphaned items task (#17061) 2026-11-11 13:33:53 +00:00
Laurent Cozic dfe4a2424e Server: Security: Check the token on the account confirmation page when confirm_email=0
With confirm_email=0 (used by the redirect after a Stripe checkout), GET users/:id/confirm did not check the token at all. Anyone could use it to find out whether a user ID exists (302 to the login page instead of 404), to see the email address of an account that has not set its password yet, and to add an "Email confirmed" notification to any account. It was used on 2026-11-07 to check which of the attacker's user IDs were real.

Setting the password was not affected, since the POST handler checks the token. The post-checkout redirect always carries a valid account confirmation token, so it keeps working.
2026-11-11 11:05:20 +00:00
Laurent Cozic fbf73e5f6a Server: Security: Do not reveal the owner's user ID on public share API
GET api/shares/:id is public so that published notes work without logging in, but it returned the owner's full user ID. Published links are indexed by search engines, so anyone could collect user IDs this way and then target the account endpoints with them. This was used in the probing attacks of 2026-10-24 and 2026-11-07: 25 of the 28 user IDs the attacker had owned published shares.

The owner ID is now only returned to the share's owner. No client uses this end point.
2026-11-11 11:05:20 +00:00
10 changed files with 91 additions and 20 deletions

No files matched your search

@@ -6,7 +6,7 @@ import Note from '@joplin/lib/models/Note';
import CommandService from '@joplin/lib/services/CommandService';
import { runNoteChat } from '@joplin/lib/services/ai/noteChat';
import { ChatRole } from '@joplin/lib/services/ai/types';
import { setupDatabaseAndSynchronizer, switchClient } from '@joplin/lib/testing/test-utils';
import { setupDatabaseAndSynchronizer, switchClient, withWarningSilenced } from '@joplin/lib/testing/test-utils';
import appReducer, { AiChatMessage, createAppDefaultState, createAppDefaultWindowState } from '../../app.reducer';
import ChatPanel from './ChatPanel';
import { WindowIdContext } from '../NewWindowOrIFrame';
@@ -186,8 +186,10 @@ describe('ChatPanel', () => {
jest.mocked(runNoteChat).mockRejectedValue(new Error('Network down'));
const view = renderPanel({ conversationId: 'chat-1', noteId: 'note-a', noteTitle: 'A', dispatch: jest.fn() });
fireEvent.change(view.getByRole('textbox'), { target: { value: 'Question' } });
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
await withWarningSilenced(/Chat failed/, async () => {
await act(async () => { fireEvent.click(view.getByRole('button', { name: 'Send' })); });
await waitFor(async () => expect(await ChatConversation.messages('chat-1')).toMatchObject([{ role: 'error', text: 'Network down' }]));
}, { requireWarning: true });
});
it('should assign an ID before messages are added to a new conversation', async () => {
@@ -17,6 +17,8 @@ describe('PromptDialog', () => {
answer: null,
});
instance.state = { visible: true, answer: 'some-answer' };
// The instance is never mounted, so a real setState() call would trigger a React warning
instance.setState = jest.fn();
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Accessing private property for test setup
(instance as unknown as any).menuIsOpened_ = menuIsOpened;
@@ -632,6 +632,38 @@ describe('ItemModel', () => {
expect((await models().userItem().byUserId(user1.id)).length).toBe(5);
});
test('should process orphaned items mixed with valid items across batches', async () => {
const { user: user1 } = await createUserAndSession(1);
const { user: user2 } = await createUserAndSession(2);
await createItemTree3(user1.id, '', '', [
{ id: '000000000000000000000000000000F1' },
]);
await createItemTree3(user2.id, '', '', [
{ id: '000000000000000000000000000000F2' },
{ id: '000000000000000000000000000000F3' },
{ id: '000000000000000000000000000000F4' },
{ id: '000000000000000000000000000000F5' },
]);
const f4 = await models().item().loadByJopId(user2.id, '000000000000000000000000000000F4');
await db()('user_items').where('item_id', '=', f4.id).delete();
await db()('users').where('id', '=', user1.id).delete();
await db()('user_items').where('user_id', '=', user1.id).delete();
await models().item().processOrphanedItems({ batchSize: 2 });
const items = await models().item().all();
expect(items.map(i => i.name).sort()).toEqual([
'000000000000000000000000000000F2.md',
'000000000000000000000000000000F3.md',
'000000000000000000000000000000F4.md',
'000000000000000000000000000000F5.md',
]);
expect((await models().userItem().byUserId(user2.id)).length).toBe(4);
});
test('should return multiple item contents', async () => {
const { user: user1 } = await createUserAndSession(1);
+12 -4
View File
@@ -1102,6 +1102,10 @@ export default class ItemModel extends BaseModel<Item> {
// then look up which of those ids exist in user_items. The orphan
// filter happens in TS. Per-query cost is bounded by the window size,
// not by the table size, so no statement can blow past the timeout.
//
// The window query only selects `id` so that it is an index-only scan.
// IDs are random, so any other column costs one heap page read per row,
// and walking the whole heap evicts the buffer cache used by the app.
const batchSize = options.batchSize ?? 10000;
let batchNum = 0;
let totalProcessed = 0;
@@ -1114,7 +1118,7 @@ export default class ItemModel extends BaseModel<Item> {
const batchStartTime = Date.now();
const windowItems: Item[] = await this.db(this.tableName)
.select(['items.id', 'items.name', 'items.owner_id'])
.select(['items.id'])
.where('items.id', '>', lastId)
.orderBy('items.id')
.limit(batchSize);
@@ -1133,14 +1137,18 @@ export default class ItemModel extends BaseModel<Item> {
.select('item_id')
.whereIn('item_id', windowItemIds);
const userItemIds = new Set(existingUserItems.map(u => u.item_id));
const orphanedItems = windowItems.filter(i => !userItemIds.has(i.id));
const orphanedItemIds = windowItemIds.filter(id => !userItemIds.has(id));
if (!orphanedItems.length) {
if (!orphanedItemIds.length) {
const batchDuration = Date.now() - batchStartTime;
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - No orphans in window of ${windowItems.length} items (${batchDuration}ms)`);
continue;
}
const orphanedItems: Item[] = await this.db(this.tableName)
.select(['id', 'name', 'owner_id'])
.whereIn('id', orphanedItemIds);
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Found ${orphanedItems.length} orphaned items`);
for (const o of orphanedItems) {
modelLogger.info(`OrphanTrace: found orphan item=${o.id} owner=${o.owner_id} name=${o.name}`);
@@ -1172,7 +1180,7 @@ export default class ItemModel extends BaseModel<Item> {
const restoreCount = Array.from(itemsToRestoreByUser.values()).reduce((sum, items) => sum + items.length, 0);
modelLogger.info(`processOrphanedItems: Batch ${batchNum} - Restoring ${restoreCount} items for ${itemsToRestoreByUser.size} users`);
for (const [userId, items] of itemsToRestoreByUser) {
await this.models().userItem().addMulti(userId, items);
await this.models().userItem().addMulti(userId, items, { ignoreAlreadyExists: true });
}
}
@@ -87,6 +87,10 @@ describe('shares', () => {
const result = await getApi<Share>('', `shares/${share.id}`);
expect(result.id).toBe(share.id);
expect(result.type).toBe(ShareType.PublishedFolder);
expect(result.owner_id).toBeUndefined();
const ownerResult = await getApi<Share>(session.id, `shares/${share.id}`);
expect(ownerResult.owner_id).toBe(share.owner_id);
});
test('should not reveal whether an email has an account', async () => {
+4 -1
View File
@@ -132,7 +132,10 @@ router.get('api/shares/:id', async (path: SubPath, ctx: AppContext) => {
if (share && (share.type === ShareType.Note || share.type === ShareType.PublishedFolder)) {
// No authentication is necessary - anyone who knows the share ID is allowed
// to access the file. It is essentially public.
return shareModel.toApiOutput(share);
const output = await shareModel.toApiOutput(share) as Share;
// Published links are indexed by search engines, and the owner's ID would let anyone target their account
if (ctx.joplin.owner?.id !== share.owner_id) delete output.owner_id;
return output;
}
throw new ErrorNotFound();
@@ -222,6 +222,9 @@ describe('index/users', () => {
// "confirm_email" is set to 0.
user1 = await models().user().load(user1.id);
expect(user1.email_confirmed).toBe(0);
// The token is still required
await expectHttpError(() => execRequest('', 'GET', path, null, { query: { token: 'invalid', confirm_email: '0' } }), ErrorForbidden.httpCode);
});
test('should allow user to verify their email', async () => {
+6 -1
View File
@@ -166,7 +166,12 @@ router.get('users/:id/confirm', async (path: SubPath, ctx: AppContext, error: Er
}
};
if (ctx.query.confirm_email !== '0') await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
if (ctx.query.confirm_email !== '0') {
await models.user().processEmailConfirmation(userId, token, beforeChangingEmailHandler);
} else {
// Otherwise this page would reveal whether an account exists, and the email of an account without a password
await models.token().checkToken(userId, token, TokenPurpose.AccountConfirmation);
}
const user = await models.user().load(userId);
if (!user) throw new ErrorNotFound(`No such user: ${userId}`);
@@ -63,10 +63,12 @@ export default async function(env: Env, models: Models, config: Config, services
run: (models: Models) => models.user().handleOversizedAccounts(),
},
// Walks the whole items table, so it runs once a day rather than
// hourly. Orphans are rare and clients work around them meanwhile.
{
id: TaskId.ProcessOrphanedItems,
description: taskIdToLabel(TaskId.ProcessOrphanedItems),
schedule: '15 * * * *',
schedule: '15 4 * * *',
run: (models: Models) => models.item().processOrphanedItems(),
},
+20 -10
View File
@@ -1,20 +1,35 @@
import { readFile } from 'fs/promises';
import { createTempDir } from './fs.test';
import { newHttpError, sendMessage, startServer, stopServer } from './ipc';
import { IpcMessageHandler, IpcServer, newHttpError, sendMessage, startServer as startServerBase, stopServer } from './ipc';
const getRandomPort = () => {
return Math.floor(Math.random() * (65535 - 20000 + 1)) + 20000;
// Stay below the OS ephemeral port ranges (Linux: 32768+, macOS/Windows: 49152+), otherwise an outgoing
// connection may hold the port, which `tcp-port-used` reports as free but `listen()` rejects with EADDRINUSE.
return Math.floor(Math.random() * 20000) + 10000;
};
let startedServers: IpcServer[] = [];
const startServer = async (startPort: number, secretKeyFilePath: string, messageHandler: IpcMessageHandler) => {
const server = await startServerBase(startPort, secretKeyFilePath, messageHandler);
startedServers.push(server);
return server;
};
describe('ipc', () => {
afterEach(async () => {
for (const server of startedServers) await stopServer(server);
startedServers = [];
});
it('should send and receive messages', async () => {
const tempDir = await createTempDir();
const secretFilePath = `${tempDir}/secret.txt`;
const serverPort1 = getRandomPort();
const serverPort2 = serverPort1 + 5;
const server1 = await startServer(serverPort1, secretFilePath, async (request) => {
await startServer(serverPort1, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello1',
@@ -24,7 +39,7 @@ describe('ipc', () => {
throw newHttpError(404);
});
const server2 = await startServer(serverPort2, secretFilePath, async (request) => {
await startServer(serverPort2, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello2',
@@ -83,9 +98,6 @@ describe('ipc', () => {
{ port: serverPort2, response: { text: 'hello2' } },
]);
}
await stopServer(server1);
await stopServer(server2);
});
it('should not process message if secret is invalid', async () => {
@@ -93,7 +105,7 @@ describe('ipc', () => {
const secretFilePath = `${tempDir}/secret.txt`;
const serverPort = getRandomPort();
const server = await startServer(serverPort, secretFilePath, async (request) => {
await startServer(serverPort, secretFilePath, async (request) => {
if (request.action === 'testing') {
return {
text: 'hello1',
@@ -124,8 +136,6 @@ describe('ipc', () => {
expect(responses.length).toBe(1);
}
await stopServer(server);
});
});