CVE-2023-0643 (TypeScript, published 2023-02-02T12:15Z)
Description: value: Improper Handling of Additional Special Element in GitHub repository squidex/squidex prior to 7.4.0.
Severity: nan (CVSS v3 base score: 6.1)
Weakness: CWE-167 \u2014 Improper Handling of Additional Special Element
The product receives input from an upstream component, but it does not handle or incorrectly handles when an additional unexpected special element is provided.
Repository: https://github.com/squidex/squidex
Files changed:
frontend/src/app/declarations.d.ts
frontend/src/app/features/rules/pages/rule/rule-page.component.ts
frontend/src/app/features/rules/pages/simulator/rule-simulator-page.component.ts
frontend/src/app/features/rules/shared/actions/generic-action.component.html
frontend/src/app/framework/angular/markdown.directive.spec.ts
frontend/src/app/framework/angular/markdown.directive.ts
frontend/src/app/framework/angular/pager.component.ts
frontend/src/app/framework/angular/pipes/markdown.pipe.spec.ts
frontend/src/app/framework/angular/pipes/markdown.pipe.ts
frontend/src/app/framework/internal.ts
frontend/src/app/framework/services/localizer.service.ts
frontend/src/app/framework/utils/date-time.spec.ts
frontend/src/app/framework/utils/markdown.ts
frontend/src/app/framework/utils/text-measurer.ts
frontend/src/app/shared/services/history.service.spec.ts
frontend/src/app/shared/services/history.service.ts
frontend/src/app/shared/services/rules.service.spec.ts
frontend/src/app/shared/services/schemas.service.spec.ts
frontend/src/app/shared/state/asset-scripts.state.spec.ts
frontend/src/app/shared/state/asset-scripts.state.ts
frontend/src/app/shared/state/resolvers.spec.ts
frontend/src/app/shared/state/rules.forms.ts
frontend/src/app/shared/state/table-settings.spec.ts
Commit message: Escape HTML in markdown. (#955)

* Escape HTML in markdown.

* Escape variables for message history.

* Rename escape method.

Vulnerable code:
import { RuleConfigured } from '../messages';
import { RuleConfigured } from '../messages';
                <span [sqxMarkdown]="property.description" [inline]="true" [html]="true"></span>
import { marked } from 'marked';

const RENDERER_DEFAULT = new marked.Renderer();
const RENDERER_INLINE = new marked.Renderer();

RENDERER_DEFAULT.link = (href, _, text) => {
    if (href && href.startsWith('mailto')) {
        return text;
    } else {
        return `<a href="${href}" target="_blank", rel="noopener">${text} <i class="icon-external-link"></i></a>`;
    }
};

RENDERER_INLINE.paragraph = (text) => {
    return text;
};

RENDERER_INLINE.link = RENDERER_DEFAULT.link;
    @Input()
    public html = false;

        const markdown = this.markdown;
        } else if (this.optional && markdown.indexOf('!') !== 0) {
            const renderer = this.inline ? RENDERER_INLINE : RENDERER_DEFAULT;

            html = marked(this.markdown, { renderer });
        if (!this.html && (!html || html === this.markdown || html.indexOf('<') < 0)) {
            this.renderer.setProperty(this.element.nativeElement, 'textContent', html);
        } else {
import { PagingInfo } from '../state';
        const actual = new MarkdownInlinePipe().transform('[link-name](link-url)');
        const actual = new MarkdownInlinePipe().transform('*bold*');
            const actual = new MarkdownInlinePipe().transform(x);
        const actual = new MarkdownPipe().transform('[link-name](link-url)');
        const actual = new MarkdownPipe().transform('*bold*');
            const actual = new MarkdownPipe().transform(x);
import { marked } from 'marked';

const renderer = new marked.Renderer();

renderer.link = (href, _, text) => {
    if (href && href.startsWith('mailto')) {
        return text;
    } else {
        return `<a href="${href}" target="_blank", rel="noopener">${text} <i class="icon-external-link"></i></a>`;
    }
};

const inlinerRenderer = new marked.Renderer();

inlinerRenderer.paragraph = (text) => {
    return text;
};

inlinerRenderer.link = renderer.link;
        if (text) {
            return marked(text, { renderer });
        } else {
            return '';
        }
        if (text) {
            return marked(text, { renderer: inlinerRenderer });
        } else {
            return '';
        }
}
import { compareStrings } from '../utils/array-helper';
import { DateHelper } from '..';
import { Types } from '../internal';
import { ApiUrlConfig, DateTime, HistoryEventDto, HistoryService, Version } from '@app/shared/internal';
import { Observable, of } from 'rxjs';
import { ApiUrlConfig, DateTime, pretifyError, Version } from '@app/framework';
const REPLACEMENT_TEMP = '$TEMP$';

    const userName = (userId: string) => {
        const parts = userId.split(':');

        if (parts.length === 1) {
            return users.getUser(parts[0], null).pipe(map(u => u.displayName));
        } else if (parts[0] === 'subject') {
            return users.getUser(parts[1], null).pipe(map(u => u.displayName));
        } else if (parts[1].endsWith('client')) {
            return of(parts[1]);
        } else {
            return of(`${parts[1]}-client`);
        }
    };
    let foundUserId: string | null = null;
    message = message.replace(/{([^\s:]*):([^}]*)}/, (match: string, type: string, id: string) => {
        if (type === 'user') {
            foundUserId = id;
            return REPLACEMENT_TEMP;
        } else {
            return id;
    });
    message = message.replace(/{([^}]*)}/g, (match: string, marker: string) => {
        return `<span class="marker-ref">${marker}</span>`;
    });
    if (foundUserId) {
        return userName(foundUserId).pipe(map(t => message.replace(REPLACEMENT_TEMP, `<span class="user-ref">${t}</span>`)));
    return of(message);
import { RuleCompletions } from '..';
import { SchemaCompletions } from '..';
import { AppsService, AssetScriptsPayload } from '../services/apps.service';
import { createAssetScripts } from '../services/apps.service.spec';
import { AppsService, AssetScripts, AssetScriptsPayload } from '../services/apps.service';
import { ContentsService } from '../services/contents.service';
import { createContent } from '../services/contents.service.spec';
import { RuleElementDto } from '../services/rules.service';
import { FieldWrappings } from '..';

Fixed code:

declare namespace marked {
    export function escape(input: string): string;
}
import { RuleConfigured } from './../messages';
import { RuleConfigured } from './../messages';
                <span [sqxMarkdown]="property.description" [inline]="true"></span>
/*
 * Squidex Headless CMS
 *
 * @license
 * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
 */

import { Renderer2 } from '@angular/core';
import { IMock, It, Mock, Times } from 'typemoq';
import { MarkdownDirective } from './markdown.directive';

describe('MarkdownDirective', () => {
    let renderer: IMock<Renderer2>;
    let markdownElement = {};
    let markdownDirective: MarkdownDirective;

    beforeEach(() => {
        renderer = Mock.ofType<Renderer2>();

        markdownElement = {};
        markdownDirective = new MarkdownDirective(markdownElement as any, renderer.object);
    });

    it('should render empty text as text', () => {
        markdownDirective.markdown = '';
        markdownDirective.ngOnChanges();

        verifyTextRender('');
    });

    it('should render as text if result has no tags', () => {
        markdownDirective.inline = true;
        markdownDirective.markdown = 'markdown';
        markdownDirective.ngOnChanges();

        verifyTextRender('markdown');
    });

    it('should render as text if optional', () => {
        markdownDirective.optional = true;
        markdownDirective.markdown = '**bold**';
        markdownDirective.ngOnChanges();

        verifyTextRender('**bold**');
    });

    it('should render if optional with exclamation', () => {
        markdownDirective.optional = true;
        markdownDirective.markdown = '!**bold**';
        markdownDirective.ngOnChanges();

        verifyHtmlRender('<strong>bold</strong>');
    });

    it('should render as HTML if allowed', () => {
        markdownDirective.inline = false;
        markdownDirective.markdown = '**bold**';
        markdownDirective.ngOnChanges();

        verifyHtmlRender('<p><strong>bold</strong></p>\n');
    });

    it('should render as inline HTML if allowed', () => {
        markdownDirective.markdown = '!**bold**';
        markdownDirective.ngOnChanges();

        verifyHtmlRender('<strong>bold</strong>');
    });

    it('should render HTML escaped', () => {
        markdownDirective.inline = false;
        markdownDirective.markdown = '<h1>Header</h1>';
        markdownDirective.ngOnChanges();

        verifyHtmlRender('<p>&lt;h1&gt;Header&lt;/h1&gt;</p>\n');
    });

    function verifyTextRender(text: string) {
        renderer.verify(x => x.setProperty(It.isAny(), 'textContent', text), Times.once());

        expect().nothing();
    }

    function verifyHtmlRender(text: string) {
        renderer.verify(x => x.setProperty(It.isAny(), 'innerHTML', text), Times.once());

        expect().nothing();
    }
});
import { renderMarkdown } from '@app/framework/internal';
        let markdown = this.markdown;

        const hasExclamation = markdown.indexOf('!') === 0;

        if (hasExclamation) {
            markdown = markdown.substring(1);
        }
        } else if (this.optional && !hasExclamation) {
            html = renderMarkdown(markdown, this.inline);
        const hasHtml = html.indexOf('<') >= 0;

        if (hasHtml) {
        } else {
            this.renderer.setProperty(this.element.nativeElement, 'textContent', html);
import { PagingInfo } from './../state';
    const pipe = new MarkdownInlinePipe();

        const actual = pipe.transform('[link-name](link-url)');
        const actual = pipe.transform('*bold*');
    it('should escape input html', () => {
        const actual = pipe.transform('<h1>Header</h1>');

        expect(actual).toBe('&lt;h1&gt;Header&lt;/h1&gt;');
    });

            const actual = pipe.transform(x);
    const pipe = new MarkdownPipe();

        const actual = pipe.transform('[link-name](link-url)');
        const actual = pipe.transform('*bold*');
    it('should escape input html', () => {
        const actual = pipe.transform('<h1>Header</h1>');

        expect(actual).toBe('<p>&lt;h1&gt;Header&lt;/h1&gt;</p>\n');
    });

            const actual = pipe.transform(x);
import { renderMarkdown } from '@app/framework/internal';
        return renderMarkdown(text, false);
        return renderMarkdown(text, true);
}
export * from './utils/markdown';
import { compareStrings } from './../utils/array-helper';
import { DateHelper } from './..';
/*
 * Squidex Headless CMS
 *
 * @license
 * Copyright (c) Squidex UG (haftungsbeschränkt). All rights reserved.
 */

import { marked } from 'marked';

function renderLink(href: string, _: string, text: string) {
    if (href && href.startsWith('mailto')) {
        return text;
    } else {
        return `<a href="${href}" target="_blank", rel="noopener">${text} <i class="icon-external-link"></i></a>`;
    }
}

function renderInlineParagraph(text: string) {
    return text;
}

const RENDERER_DEFAULT = new marked.Renderer();
const RENDERER_INLINE = new marked.Renderer();

RENDERER_INLINE.paragraph = renderInlineParagraph;
RENDERER_INLINE.link = renderLink;
RENDERER_DEFAULT.link = renderLink;

export function renderMarkdown(input: string | undefined | null, inline: boolean) {
    if (!input) {
        return '';
    }

    input = escapeHTML(input);

    if (inline) {
        return marked(input, { renderer: RENDERER_INLINE });
    } else {
        return marked(input, { renderer: RENDERER_DEFAULT });
    }
}

const escapeTestNoEncode = /[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/;
const escapeReplaceNoEncode = new RegExp(escapeTestNoEncode.source, 'g');
const escapeReplacements = {
  '&' : '&amp;',
  '<' : '&lt;',
  '>' : '&gt;',
  '"' : '&quot;',
  '\'': '&#39;',
};

const getEscapeReplacement = (ch: string) => escapeReplacements[ch];

export function escapeHTML(html: string) {
    if (escapeTestNoEncode.test(html)) {
        return html.replace(escapeReplaceNoEncode, getEscapeReplacement);
    }

    return html;
}
import { Types } from './../internal';
import { firstValueFrom, of } from 'rxjs';
import { IMock, Mock } from 'typemoq';
import { ApiUrlConfig, DateTime, formatHistoryMessage, HistoryEventDto, HistoryService, UsersProviderService, Version } from '@app/shared/internal';

describe('formatHistoryMessage', () => {
    let userProvider: IMock<UsersProviderService>;

    beforeEach(() => {
        userProvider = Mock.ofType<UsersProviderService>();
    });

    it('should provide simple message', async () => {
        const message = await firstValueFrom(formatHistoryMessage('message', userProvider.object));

        expect(message).toEqual('message');
    });

    it('should embed marker', async () => {
        const message = await firstValueFrom(formatHistoryMessage('{Name}', userProvider.object));

        expect(message).toEqual('<span class="marker-ref">Name</span>');
    });

    it('should embed marker ref and escape HTML', async () => {
        const message = await firstValueFrom(formatHistoryMessage('{<h1>HTML</h1>}', userProvider.object));

        expect(message).toEqual('<span class="marker-ref">&lt;h1&gt;HTML&lt;/h1&gt;</span>');
    });

    it('should embed user ref with unknown type', async () => {
        const message = await firstValueFrom(formatHistoryMessage('{unknown:User1}', userProvider.object));

        expect(message).toEqual('<span class="user-ref">User1</span>');
    });

    it('should embed user ref with client', async () => {
        const message = await firstValueFrom(formatHistoryMessage('{user:unknown:Client1}', userProvider.object));

        expect(message).toEqual('<span class="user-ref">Client1-client</span>');
    });

    it('should embed user ref with client ending with client', async () => {
        const message = await firstValueFrom(formatHistoryMessage('{user:client:Sample-Client}', userProvider.object));

        expect(message).toEqual('<span class="user-ref">Sample-Client</span>');
    });

    it('should embed user ref with subject', async () => {
        userProvider.setup(x => x.getUser('1', null))
            .returns(() => of({ id: '1', displayName: 'User1' }));

        const message = await firstValueFrom(formatHistoryMessage('{user:subject:1}', userProvider.object));

        expect(message).toEqual('<span class="user-ref">User1</span>');
    });

    it('should embed user ref with id', async () => {
        userProvider.setup(x => x.getUser('1', null))
            .returns(() => of({ id: '1', displayName: 'User1' }));

        const message = await firstValueFrom(formatHistoryMessage('{user:1}', userProvider.object));

        expect(message).toEqual('<span class="user-ref">User1</span>');
    });
});
import { firstValueFrom, from, Observable } from 'rxjs';
import { ApiUrlConfig, DateTime, escapeHTML, pretifyError, Version } from '@app/framework';
    async function getUserName(id: string): Promise<string> {
        const user = await firstValueFrom(users.getUser(id, null));
        return user.displayName;
    }
    async function format(message: string): Promise<string> {
        const placeholderMatches = message.matchAll(/{(?<type>[^\s:]*):(?<id>[^}]*)}/g) || [];
        const placeholderValues: string[] = [];

        for (const match of placeholderMatches) {
            const { id, type } = match.groups!;

            if (type !== 'user') {
                placeholderValues.push(id);
                continue;
            }

            const parts = id.split(':');

            if (parts.length === 1) {
                placeholderValues.push(await getUserName(parts[0]));
            } else if (parts[0] === 'subject') {
                placeholderValues.push(await getUserName(parts[1]));
            } else if (parts[1].toLowerCase().endsWith('client')) {
                placeholderValues.push(parts[1]);
            } else {
                placeholderValues.push(`${parts[1]}-client`);
            }
        message = message.replace(/{([^\s:]*):([^}]*)}/, () => {
            return `<span class="user-ref">${escapeHTML(placeholderValues.shift() || '')}</span>`;
        });

        message = message.replace(/{([^}]*)}/g, (match: string, marker: string) => {
            return `<span class="marker-ref">${escapeHTML(marker)}</span>`;
        });
        return message;
    return from(format(message));
import { RuleCompletions } from './..';
import { SchemaCompletions } from './..';
import { AppsService, AssetScriptsPayload } from './../services/apps.service';
import { createAssetScripts } from './../services/apps.service.spec';
import { AppsService, AssetScripts, AssetScriptsPayload } from './../services/apps.service';
import { ContentsService } from './../services/contents.service';
import { createContent } from './../services/contents.service.spec';
import { RuleElementDto } from './../services/rules.service';
import { FieldWrappings } from './..';