Skip to content

Commit a03eab8

Browse files
Merge pull request #3316 from flyingbee2012/biwu/versionbump949
Version bump to 9.49.0
2 parents fae88b3 + 8891fbe commit a03eab8

36 files changed

Lines changed: 977 additions & 32 deletions

File tree

demo/scripts/controlsV2/demoButtons/spaceBeforeAfterButtons.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export const spaceAfterButton: RibbonButton<typeof spaceAfterButtonKey> = {
1212
key: spaceAfterButtonKey,
1313
unlocalizedText: 'Remove space after',
1414
iconName: 'CaretDown8',
15-
isChecked: formatState => !formatState.marginBottom || parseInt(formatState.marginBottom) <= 0,
15+
isChecked: formatState => !!formatState.marginBottom && parseInt(formatState.marginBottom) > 0,
1616
onClick: editor => {
1717
const marginBottom = getFormatState(editor).marginBottom;
1818
setParagraphMargin(

packages/roosterjs-content-model-api/lib/modelApi/list/setListType.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,11 @@ export function setListType(
110110
}
111111
);
112112

113+
if (removeMargins) {
114+
newListItem.format.marginBottom = '0px';
115+
newListItem.format.marginTop = '0px';
116+
}
117+
113118
if (block.blockType == 'Paragraph') {
114119
setParagraphNotImplicit(block);
115120
}
Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
1+
import { stripInvisibleUnicode } from 'roosterjs-content-model-dom';
2+
13
/**
24
* @internal Check if there is XSS attack in the link
35
* @param link The link to be checked
4-
* @returns The safe link, or empty string if there is XSS attack
5-
* @remarks This function checks for patterns like s\nc\nr\ni\np\nt: to prevent XSS attacks. This may block some valid links,
6+
* @returns The safe link with invisible Unicode characters stripped, or empty string if there is XSS attack
7+
* @remarks This function strips invisible Unicode characters (zero-width chars, Unicode Tags, etc.)
8+
* and checks for patterns like s\nc\nr\ni\np\nt: to prevent XSS attacks. This may block some valid links,
69
* but it is necessary for security reasons. We treat the word "script" as safe if there are "/" before it.
710
*/
811
export function checkXss(link: string): string {
9-
return link.match(/^[^\/]*s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : link;
12+
// Defense-in-depth: strip invisible Unicode even if already handled elsewhere
13+
const sanitized = stripInvisibleUnicode(link);
14+
return sanitized.match(/^[^\/]*s\n*c\n*r\n*i\n*p\n*t\n*:/i) ? '' : sanitized;
1015
}

packages/roosterjs-content-model-api/lib/publicApi/utils/formatParagraphWithContentModel.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,11 @@ export function formatParagraphWithContentModel(
1717
(model, context) => {
1818
splitSelectedParagraphByBr(model);
1919

20-
const paragraphs = getSelectedParagraphs(model, true /*mutate*/);
20+
const paragraphs = getSelectedParagraphs(
21+
model,
22+
true /*mutate*/,
23+
false /*removeUnmeaningful*/
24+
);
2125

2226
paragraphs.forEach(setStyleCallback);
2327
context.newPendingFormat = 'preserve';

packages/roosterjs-content-model-api/test/modelApi/list/setListTypeTest.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -166,7 +166,7 @@ describe('indent', () => {
166166
},
167167
isSelected: false,
168168
},
169-
format: {},
169+
format: { marginBottom: '0px', marginTop: '0px' },
170170
},
171171
],
172172
});

packages/roosterjs-content-model-api/test/publicApi/utils/checkXssTest.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,36 @@ describe('checkXss', () => {
3030
const link = 'https://example.com/script:.js';
3131
expect(checkXss(link)).toBe(link);
3232
});
33+
34+
it('should strip invisible Unicode from link', () => {
35+
const link = 'https://www\u200B.example\u200C.com';
36+
expect(checkXss(link)).toBe('https://www.example.com');
37+
});
38+
39+
it('should strip invisible Unicode from mailto link', () => {
40+
const link = 'mailto:\u200Buser@example.com';
41+
expect(checkXss(link)).toBe('mailto:user@example.com');
42+
});
43+
44+
it('should detect XSS hidden behind invisible Unicode in script:', () => {
45+
// script: with zero-width spaces between characters should still be caught
46+
const link = 's\u200Bc\u200Cr\u200Di\u200Ep\u200Ft:alert(1)';
47+
expect(checkXss(link)).toBe('');
48+
});
49+
50+
it('should strip Unicode Tags (supplementary plane) from link', () => {
51+
// U+E0061 = \uDB40\uDC61 (Tag Latin Small Letter A)
52+
const link = 'mailto:\uDB40\uDC61user@example.com';
53+
expect(checkXss(link)).toBe('mailto:user@example.com');
54+
});
55+
56+
it('should strip bidirectional marks from link', () => {
57+
const link = 'mailto:\u202Auser\u202E@example.com';
58+
expect(checkXss(link)).toBe('mailto:user@example.com');
59+
});
60+
61+
it('should strip invisible Unicode from mailto subject and body', () => {
62+
const link = 'mailto:user@example.com?subject=Hello\u200BWorld&body=Test\u200CContent';
63+
expect(checkXss(link)).toBe('mailto:user@example.com?subject=HelloWorld&body=TestContent');
64+
});
3365
});

packages/roosterjs-content-model-api/test/publicApi/utils/formatParagraphWithContentModelTest.ts

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,11 @@ import {
1111
} from 'roosterjs-content-model-types';
1212
import {
1313
createContentModelDocument,
14+
createListItem,
15+
createListLevel,
1416
createParagraph,
17+
createTable,
18+
createTableCell,
1519
createText,
1620
} from 'roosterjs-content-model-dom';
1721

@@ -132,4 +136,107 @@ describe('formatParagraphWithContentModel', () => {
132136
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1);
133137
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledWith(model);
134138
});
139+
140+
it('multiple paragraphs selected', () => {
141+
model = createContentModelDocument();
142+
const para1 = createParagraph();
143+
const para2 = createParagraph();
144+
const text1 = createText('test1');
145+
const text2 = createText('test2');
146+
147+
text1.isSelected = true;
148+
text2.isSelected = true;
149+
150+
para1.segments.push(text1);
151+
para2.segments.push(text2);
152+
model.blocks.push(para1, para2);
153+
154+
formatParagraphWithContentModel(
155+
editor,
156+
apiName,
157+
paragraph => (paragraph.format.backgroundColor = 'red')
158+
);
159+
160+
expect(para1.format.backgroundColor).toBe('red');
161+
expect(para2.format.backgroundColor).toBe('red');
162+
});
163+
164+
it('paragraph in list item', () => {
165+
model = createContentModelDocument();
166+
const listItem = createListItem([createListLevel('UL')]);
167+
const para = createParagraph();
168+
const text = createText('item');
169+
170+
text.isSelected = true;
171+
para.segments.push(text);
172+
listItem.blocks.push(para);
173+
model.blocks.push(listItem);
174+
175+
formatParagraphWithContentModel(
176+
editor,
177+
apiName,
178+
paragraph => (paragraph.format.backgroundColor = 'blue')
179+
);
180+
181+
expect(para.format.backgroundColor).toBe('blue');
182+
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1);
183+
});
184+
185+
it('paragraph in table cell', () => {
186+
model = createContentModelDocument();
187+
const table = createTable(1);
188+
const cell = createTableCell();
189+
const para = createParagraph();
190+
const text = createText('cell content');
191+
192+
text.isSelected = true;
193+
para.segments.push(text);
194+
cell.blocks.push(para);
195+
table.rows[0].cells.push(cell);
196+
model.blocks.push(table);
197+
198+
formatParagraphWithContentModel(
199+
editor,
200+
apiName,
201+
paragraph => (paragraph.format.backgroundColor = 'green')
202+
);
203+
204+
expect(para.format.backgroundColor).toBe('green');
205+
expect(splitSelectedParagraphByBrSpy).toHaveBeenCalledTimes(1);
206+
});
207+
208+
it('returns false when no paragraphs are selected', () => {
209+
model = createContentModelDocument();
210+
const para = createParagraph();
211+
para.segments.push(createText('unselected'));
212+
model.blocks.push(para);
213+
214+
let callbackReturn: boolean | undefined;
215+
(editor.formatContentModel as jasmine.Spy).and.callFake(
216+
(callback: ContentModelFormatter, options: FormatContentModelOptions) => {
217+
context = {
218+
newEntities: [],
219+
newImages: [],
220+
deletedEntities: [],
221+
rawEvent: options.rawEvent,
222+
};
223+
callbackReturn = callback(model, context);
224+
}
225+
);
226+
227+
formatParagraphWithContentModel(editor, apiName, () => {});
228+
229+
expect(callbackReturn).toBe(false);
230+
});
231+
232+
it('passes apiName to formatContentModel options', () => {
233+
model = createContentModelDocument();
234+
235+
formatParagraphWithContentModel(editor, apiName, () => {});
236+
237+
expect(editor.formatContentModel).toHaveBeenCalledWith(
238+
jasmine.any(Function),
239+
jasmine.objectContaining({ apiName })
240+
);
241+
});
135242
});

packages/roosterjs-content-model-core/lib/editor/Editor.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
transformColor,
77
createDomToModelContextWithConfig,
88
domToContentModel,
9+
sanitizeInvisibleUnicode,
910
} from 'roosterjs-content-model-dom';
1011
import type {
1112
ContentModelDocument,
@@ -52,6 +53,8 @@ export class Editor implements IEditor {
5253
const initialModel =
5354
options.initialModel ?? createEmptyModel(this.core.format.defaultFormat);
5455

56+
sanitizeInvisibleUnicode(initialModel);
57+
5558
this.core.api.setContentModel(
5659
this.core,
5760
initialModel,

packages/roosterjs-content-model-core/lib/override/listMetadataApplier.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,10 @@ export const listItemMetadataApplier: MetadataApplier<
4747
> = {
4848
metadataDefinition: ListMetadataDefinition,
4949
applierFunction: (metadata, format, context) => {
50-
const depth = context.listFormat.nodeStack.length - 2; // Minus two for the parent element and convert length to index
50+
const depth = context.listFormat.currentLevel;
5151

5252
if (depth >= 0) {
53-
const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL';
53+
const listType = context.listFormat.nodeStack[depth + 1]?.listType ?? 'OL';
5454
const listStyleType = getAutoListStyleType(listType, metadata ?? {}, depth);
5555

5656
if (listStyleType !== undefined) {
@@ -77,10 +77,10 @@ export const listLevelMetadataApplier: MetadataApplier<
7777
> = {
7878
metadataDefinition: ListMetadataDefinition,
7979
applierFunction: (metadata, format, context) => {
80-
const depth = context.listFormat.nodeStack.length - 2; // Minus two for the parent element and convert length to index
80+
const depth = context.listFormat.currentLevel;
8181

8282
if (depth >= 0) {
83-
const listType = context.listFormat.nodeStack[depth + 1].listType ?? 'OL';
83+
const listType = context.listFormat.nodeStack[depth + 1]?.listType ?? 'OL';
8484
const listStyleType = getAutoListStyleType(listType, metadata ?? {}, depth);
8585

8686
if (listStyleType !== undefined) {

packages/roosterjs-content-model-core/test/editor/EditorTest.ts

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,12 @@ describe('Editor', () => {
2626
updateKnownColorSpy = jasmine.createSpy('updateKnownColor');
2727
createEditorCoreSpy = spyOn(createEditorCore, 'createEditorCore').and.callThrough();
2828
setContentModelSpy = jasmine.createSpy('setContentModel');
29-
createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel');
29+
createEmptyModelSpy = spyOn(createEmptyModel, 'createEmptyModel').and.callThrough();
3030
});
3131

3232
it('ctor and dispose, no options', () => {
3333
const div = document.createElement('div');
3434

35-
createEmptyModelSpy.and.callThrough();
36-
3735
const editor = new Editor(div);
3836

3937
expect(createEditorCoreSpy).toHaveBeenCalledWith(div, {});
@@ -67,7 +65,7 @@ describe('Editor', () => {
6765
} as any;
6866
const setContentModelSpy = jasmine.createSpy('setContentModel');
6967
const disposeErrorHandlerSpy = jasmine.createSpy('disposeErrorHandler');
70-
const mockedInitialModel = 'INITMODEL' as any;
68+
const mockedInitialModel = { blocks: [] } as any;
7169
const options: EditorOptions = {
7270
plugins: [mockedPlugin1, mockedPlugin2],
7371
disposeErrorHandler: disposeErrorHandlerSpy,
@@ -78,8 +76,6 @@ describe('Editor', () => {
7876
},
7977
};
8078

81-
createEmptyModelSpy.and.callThrough();
82-
8379
const editor = new Editor(div, options);
8480

8581
expect(createEditorCoreSpy).toHaveBeenCalledWith(div, options);

0 commit comments

Comments
 (0)