Compare commits

...
Author SHA1 Message Date
Laurent Cozic 98a55d175a update 2026-11-11 18:48:25 +00:00
Laurent Cozic 54a645bfec update 2026-11-11 18:13:02 +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
13 changed files with 259 additions and 35 deletions

No files matched your search

+1
View File
@@ -1839,6 +1839,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/createAccessiblePdf.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/glyphLessFont.js
packages/lib/services/ocr/utils/types.js
packages/lib/services/plugins/BasePlatformImplementation.js
packages/lib/services/plugins/BasePluginRunner.js
+1
View File
@@ -1865,6 +1865,7 @@ packages/lib/services/ocr/drivers/OcrDriverTranscribe.js
packages/lib/services/ocr/utils/createAccessiblePdf.js
packages/lib/services/ocr/utils/filterOcrText.test.js
packages/lib/services/ocr/utils/filterOcrText.js
packages/lib/services/ocr/utils/glyphLessFont.js
packages/lib/services/ocr/utils/types.js
packages/lib/services/plugins/BasePlatformImplementation.js
packages/lib/services/plugins/BasePluginRunner.js
@@ -397,6 +397,33 @@ describe('OcrService', () => {
expect(pdfBytes.length).toBeGreaterThan(jpegBuffer.length * 1.5);
});
it.each([
{ words: ['ı', 'Мир', '日本語'], rtl: false },
{ words: ['אור', 'חדש'], rtl: true },
{ words: ['نور', 'باب', 'عام2024'], rtl: true },
])('should create an accessible PDF with text that can be extracted: $words', async ({ words, rtl }) => {
const jpegBuffer = await fs.readFile(`${supportDir}/photo.jpg`);
// Like Tesseract, words are in logical order, so right-to-left words are laid out from the right
const ocrDetails: PdfOcrDetails = {
version: 1,
pages: [{
lines: [{ words: words.map((t, i) => {
const x = rtl ? 700 - (i + 1) * 150 : 20 + i * 150;
return { t, bb: [x, x + 130, 20, 60] };
}) }],
}],
};
const pdfBytes = await createAccessiblePdf([{ buffer: jpegBuffer, width: 800, height: 400 }], JSON.stringify(ocrDetails));
const pdfPath = join(Setting.value('tempDir'), 'accessible.pdf');
await fs.writeFile(pdfPath, pdfBytes);
const textByPage = await shim.pdfExtractEmbeddedText(pdfPath);
expect(textByPage.length).toBe(1);
expect(textByPage[0].split(/\s+/)).toEqual(words);
});
it('should truncate long OCR text', async () => {
const service = newOcrService();
service.testing_setOcrMaxSize(10);
@@ -1,60 +1,188 @@
import { PDFDocument, PDFFont, PDFPage, rgb, StandardFonts } from 'pdf-lib';
import { beginText, endText, PDFDocument, PDFHexString, PDFOperator, PDFPage, PDFRef, PDFString, setCharacterSqueeze, setFontAndSize, setTextMatrix, setTextRenderingMode, showText, TextRenderingMode } from 'pdf-lib';
import { PdfOcrDetails, RecognizeResultLine } from './types';
import glyphLessFontBase64 from './glyphLessFont';
// The PDF OCR images are created at 2x scale by pdfToImages()
const OCR_SCALE_FACTOR = 2;
// Metrics of the glyphless font, in 1/1000 of the font size
const GLYPH_WIDTH = 500;
const FONT_ASCENT = 800;
const FONT_DESCENT = -200;
export interface PageImageWithDimensions {
buffer: Buffer;
width: number;
height: number;
}
// Maps each UTF-16 code unit (used as CID) to itself. A bfrange may only vary in its last byte and a
// block may contain at most 100 entries.
const makeToUnicodeCMap = () => {
const ranges: string[] = [];
for (let i = 0; i < 256; i++) {
const high = i.toString(16).toUpperCase().padStart(2, '0');
ranges.push(`<${high}00> <${high}FF> <${high}00>`);
}
// cSpell:disable
const blocks: string[] = [];
for (let i = 0; i < ranges.length; i += 100) {
const chunk = ranges.slice(i, i + 100);
blocks.push(`${chunk.length} beginbfrange\n${chunk.join('\n')}\nendbfrange`);
}
return [
'/CIDInit /ProcSet findresource begin',
'12 dict begin',
'begincmap',
'/CIDSystemInfo << /Registry (Adobe) /Ordering (UCS) /Supplement 0 >> def',
'/CMapName /Adobe-Identity-UCS def',
'/CMapType 2 def',
'1 begincodespacerange',
'<0000> <FFFF>',
'endcodespacerange',
...blocks,
'endcmap',
'CMapName currentdict /CMap defineresource pop',
'end',
'end',
].join('\n');
// cSpell:enable
};
// Standard fonts only support WinAnsi, so any other character (Cyrillic, CJK, etc.) would make
// pdf-lib throw. Instead, like Tesseract, we use a Type0 font whose CIDs are UTF-16 code units, all
// mapped to the same empty glyph, with a ToUnicode CMap so that the text can be extracted.
const embedGlyphLessFont = (pdfDoc: PDFDocument) => {
const context = pdfDoc.context;
const fontFile = Buffer.from(glyphLessFontBase64, 'base64');
const fontFileRef = context.register(context.flateStream(fontFile, { Length1: fontFile.length }));
// Two bytes per CID, all pointing to glyph 1
const cidToGidMap = new Uint8Array(0x10000 * 2);
for (let i = 1; i < cidToGidMap.length; i += 2) cidToGidMap[i] = 1;
const cidToGidMapRef = context.register(context.flateStream(cidToGidMap));
const fontDescriptorRef = context.register(context.obj({
Type: 'FontDescriptor',
FontName: 'GlyphLessFont',
Flags: 5,
FontBBox: [0, FONT_DESCENT, GLYPH_WIDTH, FONT_ASCENT],
ItalicAngle: 0,
Ascent: FONT_ASCENT,
Descent: FONT_DESCENT,
CapHeight: FONT_ASCENT,
StemV: 80,
FontFile2: fontFileRef,
}));
const cidFontRef = context.register(context.obj({
Type: 'Font',
Subtype: 'CIDFontType2',
BaseFont: 'GlyphLessFont',
CIDSystemInfo: {
Registry: PDFString.of('Adobe'),
Ordering: PDFString.of('Identity'),
Supplement: 0,
},
FontDescriptor: fontDescriptorRef,
CIDToGIDMap: cidToGidMapRef,
DW: GLYPH_WIDTH,
}));
const toUnicodeRef = context.register(context.flateStream(makeToUnicodeCMap()));
return context.register(context.obj({
Type: 'Font',
Subtype: 'Type0',
BaseFont: 'GlyphLessFont',
Encoding: 'Identity-H',
DescendantFonts: [cidFontRef],
ToUnicode: toUnicodeRef,
}));
};
const rtlScriptRegex = /[\p{Script=Hebrew}\p{Script=Arabic}\p{Script=Syriac}\p{Script=Thaana}\p{Script=Nko}]/u;
const isRtlLetter = (char: string) => rtlScriptRegex.test(char) && !/\p{N}/u.test(char);
const isLtrStrong = (char: string) => /[\p{L}\p{N}]/u.test(char) && !isRtlLetter(char);
// OCR text is in logical order, but viewers expect PDF text in visual (left-to-right) order and
// reorder it when extracting. So in right-to-left words, the runs are reversed, as well as the
// characters within right-to-left runs (numbers and Latin text remain left-to-right).
const toVisualOrder = (text: string) => {
const chars = Array.from(text);
if (!chars.some(isRtlLetter)) return text;
const runs: string[][] = [];
for (const char of chars) {
const lastRun = runs[runs.length - 1];
if (lastRun && isLtrStrong(lastRun[0]) === isLtrStrong(char)) {
lastRun.push(char);
} else {
runs.push([char]);
}
}
return runs.reverse().map(run => isLtrStrong(run[0]) ? run.join('') : run.reverse().join('')).join('');
};
const toUtf16Hex = (text: string) => {
let output = '';
for (let i = 0; i < text.length; i++) {
output += text.charCodeAt(i).toString(16).padStart(4, '0');
}
return output;
};
// Adds an invisible text layer to a PDF page based on OCR word positions.
// The text is rendered in "invisible" mode (render mode 3) so it doesn't
// appear visually but can be selected, copied, and read by screen readers.
const addInvisibleTextLayer = (
page: PDFPage,
lines: RecognizeResultLine[],
font: PDFFont,
_pageWidth: number,
fontRef: PDFRef,
pageHeight: number,
): void => {
) => {
const fontName = page.node.newFontDictionary('GlyphLessFont', fontRef);
const operators: PDFOperator[] = [beginText(), setTextRenderingMode(TextRenderingMode.Invisible)];
for (const line of lines) {
for (const word of line.words) {
const text = word.t;
if (!text || !text.trim()) continue;
// Bounding box format from Tesseract: [x0, x1, y0, y1]
const [x0, , y0, y1] = word.bb;
const [x0, x1, y0, y1] = word.bb;
// Convert from OCR coordinates (2x scale, origin top-left)
// to PDF coordinates (1x scale, origin bottom-left)
const pdfX = x0 / OCR_SCALE_FACTOR;
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR); // Flip Y axis and use bottom of bbox
// Calculate word height in PDF coordinates (width not currently used)
const wordWidth = (x1 - x0) / OCR_SCALE_FACTOR;
const wordHeight = (y1 - y0) / OCR_SCALE_FACTOR;
// Estimate font size based on word height
// We want the text to fit within the bounding box
const fontSize = Math.max(1, wordHeight * 0.85);
// The font's ascent-to-descent height is one em, so this makes the glyphs span the word's
// bounding box, with the baseline placed above its bottom by the descent.
const fontSize = Math.max(1, wordHeight);
const pdfX = x0 / OCR_SCALE_FACTOR;
const pdfY = pageHeight - (y1 / OCR_SCALE_FACTOR) - fontSize * FONT_DESCENT / 1000;
// Draw the text as invisible (using transparent color)
// PDF.js and screen readers can still detect and read this text
page.drawText(text, {
x: pdfX,
y: pdfY,
size: fontSize,
font: font,
color: rgb(0, 0, 0),
opacity: 0, // Make text invisible
// Note: pdf-lib doesn't directly support render mode 3 (invisible),
// but opacity: 0 achieves the same visual effect while keeping
// the text selectable
});
// Stretch the text horizontally so that its selection matches the word on the image
const hex = toUtf16Hex(toVisualOrder(text));
const naturalWidth = (hex.length / 4) * fontSize * GLYPH_WIDTH / 1000;
const horizontalScale = wordWidth > 0 ? wordWidth / naturalWidth * 100 : 100;
operators.push(
setFontAndSize(fontName, fontSize),
setCharacterSqueeze(horizontalScale),
setTextMatrix(1, 0, 0, 1, pdfX, pdfY),
showText(PDFHexString.of(hex)),
);
}
}
operators.push(endText());
page.pushOperators(...operators);
};
// Creates an accessible PDF by overlaying invisible text on top of page images.
@@ -78,8 +206,7 @@ const createAccessiblePdf = async (
const pdfDoc = await PDFDocument.create();
// Embed a standard font for the invisible text layer
const font = await pdfDoc.embedFont(StandardFonts.Helvetica);
const fontRef = embedGlyphLessFont(pdfDoc);
for (let pageIndex = 0; pageIndex < pageImages.length; pageIndex++) {
const pageImage = pageImages[pageIndex];
@@ -104,7 +231,7 @@ const createAccessiblePdf = async (
});
// Add invisible text layer on top
addInvisibleTextLayer(page, pageOcr.lines, font, pageWidth, pageHeight);
addInvisibleTextLayer(page, pageOcr.lines, fontRef, pageHeight);
}
return pdfDoc.save();
@@ -0,0 +1,7 @@
// Minimal TrueType font generated with fontTools' FontBuilder. Same idea as Tesseract's GlyphLessFont,
// used for invisible OCR text. Glyph 1 is a 500x1000 rectangle, with ascent 800 and descent -200.
// It's never drawn (render mode 3), but macOS PDFKit (Preview) needs a non-zero descent and a glyph
// outline to compute text bounds, otherwise the text can't be selected.
const glyphLessFontBase64 = 'AAEAAAAKAIAAAwAgT1MvMkUAQ7AAAAEoAAAAYGNtYXAADABzAAABkAAAADRnbHlmA24B+AAAAcwAAAAaaGVhZC9AAeoAAACsAAAANmhoZWEFFgEuAAAA5AAAACRobXR4AfQAAAAAAYgAAAAGbG9jYQANAAAAAAHEAAAABm1heHAABAAGAAABCAAAACBuYW1lGZ8ZNAAAAegAAABycG9zdJ5/ds8AAAJcAAAALQABAAAAAQAAN7DqNF8PPPUAAwPoAAAAAOcaX1kAAAAA5xpfWQAA/zgB9AMgAAAAAwACAAAAAAAAAAEAAAMg/zgAAAH0AAAAAAH0AAEAAAAAAAAAAAAAAAAAAAABAAEAAAACAAQAAQAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAwH0AZAABQAEAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAPz8/PwAAACAAIAMg/zgAAAMgAMgAAAAAAAAAAAAAAAAAAAAgAAAB9AAAAAAAAAAAAAIAAAADAAAAFAADAAEAAAAUAAQAIAAAAAQABAABAAAAIP//AAAAIP///+EAAQAAAAAAAAAAAA0AAAABAAD/OAH0AyAAAwAAFREhEQH0yAPo/BgAAAAAAAAEADYAAQAAAAAAAQANAAAAAQAAAAAAAgAHAA0AAwABBAkAAQAaABQAAwABBAkAAgAOAC5HbHlwaExlc3NGb250UmVndWxhcgBHAGwAeQBwAGgATABlAHMAcwBGAG8AbgB0AFIAZQBnAHUAbABhAHIAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAIAAAECBmdseXBoMQAAAA==';
export default glyphLessFontBase64;
@@ -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(),
},
+4
View File
@@ -378,3 +378,7 @@ instr
mixmark
Hrvatski
Defense
glyphless
bfrange
flate
Thaana