Skip to content
45 changes: 45 additions & 0 deletions app/utils/string-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
export const escapeHTML = (s: string): string => (
s.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
);

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 };
};
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';

Expand Down Expand Up @@ -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)}`
+ `<a href="${escapeHTML(urlFinal)}" rel="nofollow">`
+ `${escapeHTML(urlFinal)}</a>`
+ `${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, '<br>'));
}

didReceiveAttrs(): void {
if (!this.cookies.exists(maintenanceCookie)) {
this.getMaintenanceStatus.perform();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
>
<strong>{{t 'maintenance.title'}}</strong>
{{#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'}}
Expand Down
111 changes: 110 additions & 1 deletion tests/integration/components/maintenance-banner/component-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,134 @@ 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();
});

test('it renders maintenance message', async assert => {
server.urlPrefix = apiUrl;
server.namespace = '/v2';

server.get('/status', () => ({
meta: { version: '2.8' },
maintenance: {
message: 'longstringy',
level: 1,
},
}));

await render(hbs`{{maintenance-banner}}`);
assert.dom('.alert').includesText('longstringy');
});

test('it renders line breaks as <br>', 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: '<script>alert(1)</script>',
level: 1,
},
}));

await render(hbs`{{maintenance-banner}}`);

assert.dom('.alert script').doesNotExist();
assert.dom('.alert').includesText('<script>alert(1)</script>');
});

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.');
});
});
60 changes: 60 additions & 0 deletions tests/unit/utils/string-utils-test.ts
Original file line number Diff line number Diff line change
@@ -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'],
['<script>', '&lt;script&gt;'],
['Tom & Jerry', 'Tom &amp; Jerry'],
['"quote"', '&quot;quote&quot;'],
["'single'", '&#39;single&#39;'],
['<>&"\'', '&lt;&gt;&amp;&quot;&#39;'],
];

for (const [input, expected] of cases) {
assert.strictEqual(escapeHTML(input), expected);
}
});

test('isValidDomain validates domain correctly', assert => {
const cases: Array<[string, boolean]> = [
['google.com', true],
['sub.domain.com', true],
['abc.co', true],
['-google.com', false],
['google-.com', false],
['google..com', false],
['google', false],
['g<>gle.com', false],
['??', false],
];

for (const [input, expected] of cases) {
assert.strictEqual(isValidDomain(input), expected);
}
});

test('trimEdges splits leading and trailing punctuation', assert => {
const cases: Array<[string, { leading: string; core: string; trailing: string }]> = [
['', { leading: '', core: '', trailing: '' }],
['abc', { leading: '', core: 'abc', trailing: '' }],
['(abc)', { leading: '(', core: 'abc', trailing: ')' }],
['[(abc)]', { leading: '[(', core: 'abc', trailing: ')]' }],
['(https://google.com)', { leading: '(', core: 'https://google.com', trailing: ')' }],
];

for (const [input, expected] of cases) {
const result = trimEdges(input);
assert.strictEqual(result.leading, expected.leading);
assert.strictEqual(result.core, expected.core);
assert.strictEqual(result.trailing, expected.trailing);
}
});
});
Loading