Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,9 @@ jobs:
continue-on-error: true
run: npm run lint

- name: Test
run: npm run test:ci

- name: Build
run: npm run build

Expand Down
37 changes: 23 additions & 14 deletions web/components/Notifications/Item.vue
Original file line number Diff line number Diff line change
@@ -1,39 +1,48 @@
<script setup lang="ts">
import {
ArchiveBoxIcon,
ShieldExclamationIcon,
CheckBadgeIcon,
ExclamationTriangleIcon,
LinkIcon,
ShieldExclamationIcon,
TrashIcon,
} from "@heroicons/vue/24/solid";
import { useMutation } from "@vue/apollo-composable";
import type { NotificationFragmentFragment } from "~/composables/gql/graphql";

import { NotificationType } from "~/composables/gql/graphql";
} from '@heroicons/vue/24/solid';
import { useMutation } from '@vue/apollo-composable';
import type { NotificationFragmentFragment } from '~/composables/gql/graphql';
import { NotificationType } from '~/composables/gql/graphql';
import {
archiveNotification as archiveMutation,
deleteNotification as deleteMutation,
} from "./graphql/notification.query";
} from './graphql/notification.query';
import { Markdown } from '@/helpers/markdown';

const props = defineProps<NotificationFragmentFragment>();

const descriptionMarkup = computedAsync(async () => {
try {
return await Markdown.parse(props.description);
} catch (e) {
console.error(e)
return props.description;
}
}, '');

const icon = computed<{ component: Component; color: string } | null>(() => {
switch (props.importance) {
case "INFO":
case 'INFO':
return {
component: CheckBadgeIcon,
color: "text-green-500",
color: 'text-green-500',
};
case "WARNING":
case 'WARNING':
return {
component: ExclamationTriangleIcon,
color: "text-yellow-500",
color: 'text-yellow-500',
};
case "ALERT":
case 'ALERT':
return {
component: ShieldExclamationIcon,
color: "text-red-500",
color: 'text-red-500',
};
}
return null;
Expand Down Expand Up @@ -86,7 +95,7 @@ const mutationError = computed(() => {
<div
class="w-full flex flex-row items-center justify-between gap-2 opacity-75 group-hover/item:opacity-100 group-focus/item:opacity-100"
>
<p class="text-secondary-foreground">{{ description }}</p>
<div class="text-secondary-foreground" v-html="descriptionMarkup" />
</div>

<p v-if="mutationError" class="text-red-600">Error: {{ mutationError }}</p>
Expand Down
11 changes: 11 additions & 0 deletions web/helpers/__snapshots__/markdown.test.ts.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html

exports[`sanitization > strips javascript 1`] = `
"<p><img src="x"></p>
"
`;

exports[`sanitization > strips javascript 2`] = `
"<p><img src="x"></p>
"
`;
47 changes: 47 additions & 0 deletions web/helpers/markdown.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { baseUrl } from 'marked-base-url';
import { describe, expect, test } from 'vitest';
import { Markdown } from './markdown';

// add a random extension to the instance
const instance = Markdown.create(baseUrl('https://unraid.net'));
const parse = async (content: string) => ({
fromDefault: await Markdown.parse(content),
fromInstance: await instance.parse(content),
});

describe('sanitization', () => {
test('strips javascript', async () => {
const parsed = await parse(`<img src=x onerror=alert(1)//><script>console.log('hello')</script>`);
expect(parsed.fromDefault).toMatchSnapshot();
expect(parsed.fromInstance).toMatchSnapshot();
});

test('strips various XSS vectors', async () => {
const vectors = [
'<a href="javascript:alert(1)">click me</a>',
"<IMG SRC=JaVaScRiPt:alert('XSS')>",
'"><script>alert(document.cookie)</script>',
'<style>@import \'javascript:alert("XSS")\';</style>',
];

for (const vector of vectors) {
const parsed = await parse(vector);
expect(parsed.fromDefault).not.toContain('javascript:');
expect(parsed.fromInstance).not.toContain('javascript:');
}
});
});

describe('extensibility', () => {
test('works with other extensions', async () => {
const parsed = await parse(`[Contact](/contact)`);
expect(parsed.fromDefault).toMatchInlineSnapshot(`
"<p><a href="/contact">Contact</a></p>
"
`);
expect(parsed.fromInstance).toMatchInlineSnapshot(`
"<p><a href="https://unraid.net/contact">Contact</a></p>
"
`);
});
});
45 changes: 45 additions & 0 deletions web/helpers/markdown.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import DOMPurify from 'isomorphic-dompurify';
import { Marked, type MarkedExtension } from 'marked';

const defaultMarkedExtension: MarkedExtension = {
hooks: {
// must define as a function (instead of a lambda) to preserve/reflect bindings downstream
postprocess(html) {
return DOMPurify.sanitize(html);
},
},
};

/**
* Helper class to build or conveniently use a markdown parser.
*
* - Use `Markdown.create` to extend or customize parsing functionality.
* - Use `Markdown.parse` to conveniently parse markdown to safe html.
*/
// eslint-disable-next-line @typescript-eslint/no-extraneous-class
export class Markdown {
private static instance = Markdown.create();

/**
* Creates a `Marked` instance with default MarkedExtension's already added.
*
* Default behaviors:
* - Sanitizes html after parsing
*
* @param args any number of Marked Extensions
* @returns Marked parser instance
*/
static create(...args: Parameters<Marked['use']>) {
return new Marked(defaultMarkedExtension, ...args);
}

/**
* Parses arbitrary markdown content as sanitized html. May throw if parsing fails.
*
* @param markdownContent string of markdown content
* @returns safe, sanitized html content
*/
static async parse(markdownContent: string): Promise<string> {
return Markdown.instance.parse(markdownContent);
}
}
Loading