Add handling of posts with unsafe links. (#26129)

* Add handling of posts with unsafe links.

* Lint

* Add some tests.

* Remove interal links exception

* Add exception for links that start with siteurl, except image proxy

* Allow only permalinks

* Don't interperate regex in siteURL

* Negate the external requests helper

* Modify prop check to only check for 'true'

* Move regex outside function.
This commit is contained in:
Christopher Speller 2024-02-13 09:28:30 -08:00 committed by GitHub
parent daab9d5ff5
commit 3bf8574b0d
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 80 additions and 2 deletions

View File

@ -5,9 +5,23 @@ import {connect} from 'react-redux';
import {bindActionCreators} from 'redux';
import type {Dispatch} from 'redux';
import {getPost} from 'mattermost-redux/selectors/entities/posts';
import {openModal} from 'actions/views/modals';
import type {GlobalState} from 'types/store';
import MarkdownImage from './markdown_image';
import type {Props} from './markdown_image';
function mapStateToProps(state: GlobalState, ownProps: Props) {
const post = getPost(state, ownProps.postId);
const isUnsafeLinksPost = post?.props?.unsafe_links === 'true';
return {
isUnsafeLinksPost,
};
}
function mapDispatchToProps(dispatch: Dispatch) {
return {
@ -17,6 +31,6 @@ function mapDispatchToProps(dispatch: Dispatch) {
};
}
const connector = connect(null, mapDispatchToProps);
const connector = connect(mapStateToProps, mapDispatchToProps);
export default connector(MarkdownImage);

View File

@ -332,4 +332,12 @@ describe('components/MarkdownImage', () => {
expect(childrenWrapper).toMatchSnapshot();
});
test('should render a alt text if the link is unsafe', () => {
const props = {...baseProps, isUnsafeLinksPost: true};
const wrapper = shallow(
<MarkdownImage {...props}/>,
);
expect(wrapper.text()).toBe(props.alt);
});
});

View File

@ -36,6 +36,7 @@ export type Props = {
openModal: <P>(modalData: ModalData<P>) => void;
};
hideUtilities?: boolean;
isUnsafeLinksPost: boolean;
};
type State = {
@ -156,6 +157,9 @@ export default class MarkdownImage extends PureComponent<Props, State> {
</div>
);
}
if (this.props.isUnsafeLinksPost) {
return <>{alt}</>;
}
return (
<ExternalImage
src={src}

View File

@ -163,4 +163,23 @@ this is long text this is long text this is long text this is long text this is
});
}
});
test('unsafe mode links are rendered as text : link', () => {
const testCases = [
{input: '[link text](http://markdownlink.com)', expected: '<p>link text : http://markdownlink.com</p>'},
{input: '[link text](//markdownlink.com/test)', expected: '<p>link text : //markdownlink.com/test</p>'},
{input: '[link text](http://my.site.com/whatever)', expected: '<p>link text : http://my.site.com/whatever</p>'},
{input: '[link text](http://my.site.com/api/v4/image?url=ohno)', expected: '<p>link text : http://my.site.com/api/v4/image?url=ohno</p>'},
{input: '[link text](http://my.site.com/_redirect/pl/c18xpcpusjd88en1g4j7us31ur)', expected: '<p><a class="theme markdown__link" href="http://my.site.com/_redirect/pl/c18xpcpusjd88en1g4j7us31ur" rel="noreferrer" data-link="/_redirect/pl/c18xpcpusjd88en1g4j7us31ur">link text</a></p>'},
{input: '[link text](http://my.site.com/someteam/pl/c18xpcpusjd88en1g4j7us31ur)', expected: '<p><a class="theme markdown__link" href="http://my.site.com/someteam/pl/c18xpcpusjd88en1g4j7us31ur" rel="noreferrer" data-link="/someteam/pl/c18xpcpusjd88en1g4j7us31ur">link text</a></p>'},
{input: '[link text](http://my.site.com/_redirect/pl/c18xpcpusjd88en1g4j7us31ur/ohno)', expected: '<p>link text : http://my.site.com/_redirect/pl/c18xpcpusjd88en1g4j7us31ur/ohno</p>'},
{input: '[link text](http://my.site.com/more/stuff/here/pl/c18xpcpusjd88en1g4j7us31ur)', expected: '<p>link text : http://my.site.com/more/stuff/here/pl/c18xpcpusjd88en1g4j7us31ur</p>'},
{input: '[link text](http://myqsite.com/someteam/pl/c18xpcpusjd88en1g4j7us31ur)', expected: '<p>link text : http://myqsite.com/someteam/pl/c18xpcpusjd88en1g4j7us31ur</p>'},
];
for (const testCase of testCases) {
const output = format(testCase.input, {unsafeLinks: true, siteURL: 'http://my.site.com'});
expect(output).toEqual(testCase.expected);
}
});
});

View File

@ -7,7 +7,7 @@ import type {MarkedOptions} from 'marked';
import EmojiMap from 'utils/emoji_map';
import * as PostUtils from 'utils/post_utils';
import * as TextFormatting from 'utils/text_formatting';
import {getScheme, isUrlSafe, shouldOpenInNewTab} from 'utils/url';
import {mightTriggerExternalRequest, getScheme, isUrlSafe, shouldOpenInNewTab} from 'utils/url';
import {parseImageDimensions} from './helpers';
@ -136,6 +136,13 @@ export default class Renderer extends marked.Renderer {
public link(href: string, title: string, text: string, isUrl = false) {
let outHref = href;
if (this.formattingOptions.unsafeLinks && mightTriggerExternalRequest(href, this.formattingOptions.siteURL)) {
if (text === href) {
return text;
}
return text + ' : ' + href;
}
if (!href.startsWith('/')) {
const scheme = getScheme(href);
if (!scheme) {

View File

@ -200,6 +200,13 @@ export interface TextFormattingOptionsBase {
* Defaults to `false`.
*/
atPlanMentions: boolean;
/**
* If true, the renderer will assume links are not safe.
*
* Defaults to `false`.
*/
unsafeLinks: boolean;
}
export type TextFormattingOptions = Partial<TextFormattingOptionsBase>;
@ -227,6 +234,7 @@ const DEFAULT_OPTIONS: TextFormattingOptions = {
proxyImages: false,
editedAt: 0,
postId: '',
unsafeLinks: false,
};
/**

View File

@ -168,6 +168,24 @@ export function validateChannelUrl(url: string, intl?: IntlShape): Array<React.R
return errors;
}
// Returns true when the URL could possibly cause any external requests.
// Currently returns false only for permalinks
const permalinkPath = new RegExp('^/[0-9a-z_-]{1,64}/pl/[0-9a-z_-]{26}$');
export function mightTriggerExternalRequest(url: string, siteURL?: string): boolean {
if (siteURL && siteURL !== '') {
let standardSiteURL = siteURL;
if (standardSiteURL[standardSiteURL.length - 1] === '/') {
standardSiteURL = standardSiteURL.substring(0, standardSiteURL.length - 1);
}
if (!url.startsWith(standardSiteURL)) {
return true;
}
const afterSiteURL = url.substring(standardSiteURL.length);
return !permalinkPath.test(afterSiteURL);
}
return true;
}
export function isInternalURL(url: string, siteURL?: string): boolean {
return url.startsWith(siteURL || '') || url.startsWith('/');
}