fix: validate notification link when posting

fixes an issue that allows posting notifications containing links with
cross-site scripting attack

Signed-off-by: Heikki Hellgren <heikki.hellgren@op.fi>
This commit is contained in:
Heikki Hellgren
2024-08-15 08:37:08 +03:00
parent 963d677945
commit f1959726fe
4 changed files with 118 additions and 3 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/plugin-notifications-backend': patch
---
Validate notification link when new notification is created
@@ -67,6 +67,7 @@ export const notificationsPlugin = createBackendPlugin({
database: coreServices.database,
discovery: coreServices.discovery,
signals: signalsServiceRef,
config: coreServices.rootConfig,
},
async init({
auth,
@@ -77,6 +78,7 @@ export const notificationsPlugin = createBackendPlugin({
database,
discovery,
signals,
config,
}) {
httpRouter.use(
await createRouter({
@@ -84,6 +86,7 @@ export const notificationsPlugin = createBackendPlugin({
httpAuth,
userInfo,
logger,
config,
database,
discovery,
signals,
@@ -23,7 +23,8 @@ import request from 'supertest';
import { createRouter } from './router';
import { ConfigReader } from '@backstage/config';
import { SignalsService } from '@backstage/plugin-signals-node';
import { mockServices } from '@backstage/backend-test-utils';
import { mockCredentials, mockServices } from '@backstage/backend-test-utils';
import { NotificationSendOptions } from '@backstage/plugin-notifications-node';
function createDatabase(): PluginDatabaseManager {
return DatabaseManager.fromConfig(
@@ -47,8 +48,13 @@ describe('createRouter', () => {
const discovery = mockServices.discovery();
const userInfo = mockServices.userInfo();
const httpAuth = mockServices.httpAuth();
const httpAuth = mockServices.httpAuth({
defaultCredentials: mockCredentials.service(),
});
const auth = mockServices.auth();
const config = mockServices.rootConfig({
data: { app: { baseUrl: 'http://localhost' } },
});
beforeAll(async () => {
const router = await createRouter({
@@ -57,6 +63,7 @@ describe('createRouter', () => {
discovery,
signals: signalService,
userInfo,
config,
httpAuth,
auth,
});
@@ -75,4 +82,80 @@ describe('createRouter', () => {
expect(response.body).toEqual({ status: 'ok' });
});
});
describe('POST /', () => {
const sendNotification = async (data: NotificationSendOptions) =>
request(app)
.post('/')
.send(data)
.set('Content-Type', 'application/json')
.set('Accept', 'application/json');
it('returns error on invalid link', async () => {
const javascriptXSS = await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
// eslint-disable-next-line no-script-url
link: 'javascript:alert(document.domain)',
},
});
expect(javascriptXSS.status).toEqual(400);
const ftpLink = await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
link: 'ftp://example.com',
},
});
expect(ftpLink.status).toEqual(400);
});
it('should accept absolute http links', async () => {
const httpLink = await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
link: 'http://localhost/test',
},
});
expect(httpLink.status).toEqual(200);
const httpsLink = await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
link: 'https://example.com',
},
});
expect(httpsLink.status).toEqual(200);
});
it('should accept relative links', async () => {
const catalogLink = await sendNotification({
recipients: {
type: 'broadcast',
},
payload: {
title: 'test notification',
link: '/catalog',
},
});
expect(catalogLink.status).toEqual(200);
});
});
});
@@ -46,10 +46,12 @@ import {
} from '@backstage/plugin-notifications-common';
import { parseEntityOrderFieldParams } from './parseEntityOrderFieldParams';
import { getUsersForEntityRef } from './getUsersForEntityRef';
import { Config } from '@backstage/config';
/** @internal */
export interface RouterOptions {
logger: LoggerService;
config: Config;
database: PluginDatabaseManager;
discovery: DiscoveryService;
auth: AuthService;
@@ -65,6 +67,7 @@ export async function createRouter(
options: RouterOptions,
): Promise<express.Router> {
const {
config,
logger,
database,
auth,
@@ -79,6 +82,7 @@ export async function createRouter(
const catalogClient =
catalog ?? new CatalogClient({ discoveryApi: discovery });
const store = await DatabaseNotificationsStore.create({ database });
const frontendBaseUrl = config.getString('app.baseUrl');
const getUser = async (req: Request<unknown>) => {
const credentials = await httpAuth.credentials(req, { allow: ['user'] });
@@ -177,6 +181,18 @@ export async function createRouter(
}
};
const validateLink = (link: string) => {
const stripLeadingSlash = (s: string) => s.replace(/^\//, '');
const ensureTrailingSlash = (s: string) => s.replace(/\/?$/, '/');
const url = new URL(
stripLeadingSlash(link),
ensureTrailingSlash(frontendBaseUrl),
);
if (url.protocol !== 'https:' && url.protocol !== 'http:') {
throw new Error('Only HTTP/HTTPS links are allowed');
}
};
// TODO: Move to use OpenAPI router instead
const router = Router();
router.use(express.json());
@@ -419,13 +435,21 @@ export async function createRouter(
allow: ['service'],
});
const { title } = payload;
const { title, link } = payload;
if (!recipients || !title) {
logger.error(`Invalid notification request received`);
throw new InputError(`Invalid notification request received`);
}
if (link) {
try {
validateLink(link);
} catch (e) {
throw new InputError('Invalid link provided', e);
}
}
const origin = credentials.principal.subject;
const baseNotification = {
payload: {