diff --git a/app/utils/string-utils.ts b/app/utils/string-utils.ts
new file mode 100644
index 000000000..7e2fb92b5
--- /dev/null
+++ b/app/utils/string-utils.ts
@@ -0,0 +1,45 @@
+export const escapeHTML = (s: string): string => (
+ s.replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+);
+
+export const isValidDomain = (host: string): boolean => {
+ if (!/^[a-zA-Z0-9.-]+$/.test(host)) { return false; }
+ if (host.includes('..')) { return false; }
+ if (!host.includes('.')) { return false; }
+
+ const labels = host.split('.');
+ if (labels.some(label => /^-|-$/.test(label) || label === '')) {
+ return false;
+ }
+
+ const tld = labels[labels.length - 1];
+ return /^[a-zA-Z]{2,}$/.test(tld);
+};
+
+export interface TrimEdgesResult {
+ leading: string;
+ core: string;
+ trailing: string;
+}
+
+export const trimEdges = (s: string): TrimEdgesResult => {
+ let core = s;
+ let leading = '';
+ let trailing = '';
+
+ while (/^[(<[{【(「『〔]/.test(core)) {
+ leading += core.charAt(0);
+ core = core.slice(1);
+ }
+
+ while (/[.,!?)\]}】)」』〕、。]$/.test(core)) {
+ trailing = core.slice(-1) + trailing;
+ core = core.slice(0, -1);
+ }
+
+ return { leading, core, trailing };
+};
diff --git a/lib/osf-components/addon/components/maintenance-banner/component.ts b/lib/osf-components/addon/components/maintenance-banner/component.ts
index 3c07e4af8..dcc9860e3 100644
--- a/lib/osf-components/addon/components/maintenance-banner/component.ts
+++ b/lib/osf-components/addon/components/maintenance-banner/component.ts
@@ -1,6 +1,7 @@
import Component from '@ember/component';
import { action, computed } from '@ember/object';
import { inject as service } from '@ember/service';
+import { htmlSafe } from '@ember/string';
import { task } from 'ember-concurrency-decorators';
import Cookies from 'ember-cookies/services/cookies';
import { localClassNames } from 'ember-css-modules';
@@ -11,6 +12,11 @@ import { layout } from 'ember-osf-web/decorators/component';
import Analytics from 'ember-osf-web/services/analytics';
import CurrentUser from 'ember-osf-web/services/current-user';
+import {
+ escapeHTML,
+ isValidDomain,
+ trimEdges,
+} from 'ember-osf-web/utils/string-utils';
import styles from './styles';
import template from './template';
@@ -67,6 +73,82 @@ export default class MaintenanceBanner extends Component {
return this.maintenance && this.maintenance.level ? levelMap[this.maintenance.level - 1] : undefined;
}
+ @computed('maintenance.message')
+ get renderedMessage() {
+ const text = this.maintenance && this.maintenance.message ? this.maintenance.message : '';
+ if (!text) {
+ return htmlSafe('');
+ }
+
+ const parts: string[] = text.split(/(\s+)/);
+
+ const result = parts.map((part: string) => {
+ if (!part) {
+ return '';
+ }
+
+ // preserve whitespace
+ if (/^\s+$/.test(part)) {
+ return part;
+ }
+
+ // split by dangerous characters to support partial parsing
+ const chunks = part.split(/([<>"])/);
+
+ return chunks.map((chunk: string) => {
+ if (!chunk) {
+ return '';
+ }
+
+ // escape dangerous characters
+ if (/[<>"]/.test(chunk)) {
+ return escapeHTML(chunk);
+ }
+
+ // extract surrounding punctuation
+ const { leading, core, trailing } = trimEdges(chunk);
+
+ // https:// or http:// URLs only
+ const schemeIdx = core.search(/https?:\/\//);
+ if (schemeIdx >= 0) {
+ const textBefore = core.slice(0, schemeIdx);
+ const rest = core.slice(schemeIdx);
+
+ // Extract URL portion: stop at whitespace, HTML chars, and Japanese brackets
+ const urlMatch = /^(https?:\/\/[^\s<>"'【(「『〔】)」』〕、。]*)/.exec(rest);
+ if (!urlMatch) {
+ return escapeHTML(leading + core + trailing);
+ }
+
+ const urlRaw = urlMatch[1];
+ const textAfter = rest.slice(urlRaw.length);
+
+ // Strip trailing ASCII punctuation and unbalanced brackets from URL
+ const { core: urlFinal, trailing: urlTrailing } = trimEdges(urlRaw);
+
+ try {
+ const u = new URL(urlFinal);
+
+ if (isValidDomain(u.hostname)) {
+ return `${escapeHTML(leading)}${escapeHTML(textBefore)}`
+ + ``
+ + `${escapeHTML(urlFinal)}`
+ + `${escapeHTML(urlTrailing + textAfter + trailing)}`;
+ }
+ } catch {
+ // ignore invalid URL parsing
+ }
+
+ return `${escapeHTML(leading)}${escapeHTML(core)}${escapeHTML(trailing)}`;
+ }
+
+ return escapeHTML(chunk);
+ }).join('');
+ }).join('');
+
+ return htmlSafe(result.replace(/\n/g, '
'));
+ }
+
didReceiveAttrs(): void {
if (!this.cookies.exists(maintenanceCookie)) {
this.getMaintenanceStatus.perform();
diff --git a/lib/osf-components/addon/components/maintenance-banner/template.hbs b/lib/osf-components/addon/components/maintenance-banner/template.hbs
index 159b9cefb..223ac0e93 100644
--- a/lib/osf-components/addon/components/maintenance-banner/template.hbs
+++ b/lib/osf-components/addon/components/maintenance-banner/template.hbs
@@ -6,7 +6,7 @@
>
{{t 'maintenance.title'}}
{{#if this.maintenance.message.length}}
- {{this.maintenance.message}}
+ {{this.renderedMessage}}
{{else}}
{{t 'maintenance.line1' start=this.start end=this.end utc=this.utc htmlSafe=true}}
{{t 'maintenance.line2'}}
diff --git a/tests/integration/components/maintenance-banner/component-test.ts b/tests/integration/components/maintenance-banner/component-test.ts
index f2d45fdd2..d53137330 100644
--- a/tests/integration/components/maintenance-banner/component-test.ts
+++ b/tests/integration/components/maintenance-banner/component-test.ts
@@ -15,10 +15,13 @@ module('Integration | Component | maintenance-banner', hooks => {
setupMirage(hooks);
test('it renders no maintenance', async assert => {
- server.get('/v2/status', () => ({
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
meta: { version: '2.8' },
maintenance: null,
}));
+
await render(hbs`{{maintenance-banner}}`);
assert.dom('.alert').doesNotExist();
});
@@ -26,6 +29,7 @@ module('Integration | Component | maintenance-banner', hooks => {
test('it renders maintenance message', async assert => {
server.urlPrefix = apiUrl;
server.namespace = '/v2';
+
server.get('/status', () => ({
meta: { version: '2.8' },
maintenance: {
@@ -33,7 +37,112 @@ module('Integration | Component | maintenance-banner', hooks => {
level: 1,
},
}));
+
await render(hbs`{{maintenance-banner}}`);
assert.dom('.alert').includesText('longstringy');
});
+
+ test('it renders line breaks as
', async assert => {
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
+ meta: { version: '2.8' },
+ maintenance: {
+ message: 'line1\nline2',
+ level: 1,
+ },
+ }));
+
+ await render(hbs`{{maintenance-banner}}`);
+
+ assert.dom('.alert br').exists({ count: 1 });
+ });
+
+ test('it escapes HTML to prevent XSS', async assert => {
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
+ meta: { version: '2.8' },
+ maintenance: {
+ message: '',
+ level: 1,
+ },
+ }));
+
+ await render(hbs`{{maintenance-banner}}`);
+
+ assert.dom('.alert script').doesNotExist();
+ assert.dom('.alert').includesText('');
+ });
+
+ test('it converts valid URL to link', async assert => {
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
+ meta: { version: '2.8' },
+ maintenance: {
+ message: 'https://google.com',
+ level: 1,
+ },
+ }));
+
+ await render(hbs`{{maintenance-banner}}`);
+
+ assert.dom('.alert a')
+ .hasAttribute('href', 'https://google.com')
+ .hasText('https://google.com');
+ });
+
+ test('it does NOT link invalid domain', async assert => {
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
+ meta: { version: '2.8' },
+ maintenance: {
+ message: 'http://-google...com',
+ level: 1,
+ },
+ }));
+
+ await render(hbs`{{maintenance-banner}}`);
+
+ assert.dom('.alert a').doesNotExist();
+ assert.dom('.alert').includesText('http://-google...com');
+ });
+
+ test('it handles mixed content (text + url + newline)', async assert => {
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
+ meta: { version: '2.8' },
+ maintenance: {
+ message: 'line1\nhttps://abc.com\nline3',
+ level: 1,
+ },
+ }));
+
+ await render(hbs`{{maintenance-banner}}`);
+
+ assert.dom('.alert br').exists({ count: 2 });
+ assert.dom('.alert a').exists({ count: 1 });
+ });
+
+ test('it handles URL surrounded by parentheses', async assert => {
+ server.urlPrefix = apiUrl;
+ server.namespace = '/v2';
+ server.get('/status', () => ({
+ meta: { version: '2.8' },
+ maintenance: {
+ message: 'Please visit (https://google.com) for more info.',
+ level: 1,
+ },
+ }));
+
+ await render(hbs`{{maintenance-banner}}`);
+
+ assert.dom('.alert a')
+ .hasAttribute('href', 'https://google.com')
+ .hasText('https://google.com');
+ assert.dom('.alert').includesText('Please visit (https://google.com) for more info.');
+ });
});
diff --git a/tests/unit/utils/string-utils-test.ts b/tests/unit/utils/string-utils-test.ts
new file mode 100644
index 000000000..cf23a4a4c
--- /dev/null
+++ b/tests/unit/utils/string-utils-test.ts
@@ -0,0 +1,60 @@
+import {
+ escapeHTML,
+ isValidDomain,
+ trimEdges,
+} from 'ember-osf-web/utils/string-utils';
+
+import { module, test } from 'qunit';
+
+module('Unit | Utility | string-utils', () => {
+ test('escapeHTML escapes dangerous characters', assert => {
+ const cases: Array<[string, string]> = [
+ ['', ''],
+ ['abc', 'abc'],
+ ['