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'], + ['