Skip to content

Commit 52a2a7e

Browse files
committed
allow only the new slot-element in dynamic render
1 parent ebbb430 commit 52a2a7e

File tree

5 files changed

+143
-9
lines changed

5 files changed

+143
-9
lines changed

packages/jsonforms-vuetify-renderers/src/components/DynamicElement.vue

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,13 +24,60 @@ const allowedTags = [
2424
'style',
2525
] as const;
2626
27+
const dangerousAttributes = new Set([
28+
// HTML injection
29+
'innerhtml',
30+
'outerhtml',
31+
'srcdoc',
32+
33+
// Form manipulation
34+
'action',
35+
'formaction',
36+
'formenctype',
37+
'formmethod',
38+
'formtarget',
39+
40+
// CSS injection
41+
'style',
42+
43+
// Import/module related
44+
'import',
45+
'importmap',
46+
47+
// Meta refresh
48+
'http-equiv',
49+
50+
// Sandbox escaping
51+
'sandbox',
52+
]);
53+
2754
type AllowedTag = (typeof allowedTags)[number];
55+
2856
function isAllowedTag(value: any): value is AllowedTag {
2957
return allowedTags.includes(
3058
(typeof value === 'string' ? value.toLowerCase() : value) as AllowedTag,
3159
);
3260
}
3361
62+
function isSafeUrl(url: string): boolean {
63+
const lower = url.toLowerCase().trim();
64+
65+
if (lower.startsWith('javascript:') || lower.startsWith('data:')) {
66+
return false;
67+
}
68+
69+
return (
70+
lower.startsWith('/') ||
71+
lower.startsWith('./') ||
72+
lower.startsWith('../') ||
73+
lower.startsWith('http://') ||
74+
lower.startsWith('https://') ||
75+
lower.startsWith('mailto:') ||
76+
lower.startsWith('tel:') ||
77+
lower.startsWith('#')
78+
);
79+
}
80+
3481
export default defineComponent({
3582
name: 'dynamic-element',
3683
inheritAttrs: false,
@@ -58,8 +105,33 @@ export default defineComponent({
58105
const safeAttrs: Record<string, unknown> = {};
59106
for (const [key, val] of Object.entries(attrs)) {
60107
const lowerKey = key.toLowerCase();
61-
if (lowerKey === 'innerhtml') continue;
62-
if (lowerKey.startsWith('on')) continue;
108+
if (dangerousAttributes.has(lowerKey)) {
109+
if (import.meta.env.DEV) {
110+
console.warn(
111+
`[DynamicElement] Dangerous attribute "${key}" was blocked.`,
112+
);
113+
}
114+
continue;
115+
}
116+
if (lowerKey.startsWith('on')) {
117+
if (import.meta.env.DEV) {
118+
console.warn(
119+
`[DynamicElement] Event handler "${key}" was blocked.`,
120+
);
121+
}
122+
continue;
123+
}
124+
if (
125+
(lowerKey === 'href' || lowerKey === 'src') &&
126+
typeof val === 'string'
127+
) {
128+
if (!isSafeUrl(val)) {
129+
if (import.meta.env.DEV) {
130+
console.warn(`[DynamicElement] Unsafe URL blocked: ${val}`);
131+
}
132+
continue;
133+
}
134+
}
63135
safeAttrs[key] = val;
64136
}
65137
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
<script lang="ts">
2+
import { defineComponent, h } from 'vue';
3+
4+
const allowedSlotAttributes = new Set([
5+
'name', // Primary slot attribute
6+
'slot', // For slot assignment
7+
'id', // For targeting
8+
'class', // For styling
9+
'style', // Safe for slots since they're just placeholders
10+
'title', // For accessibility
11+
'aria-label',
12+
'aria-describedby',
13+
'role',
14+
]);
15+
16+
export default defineComponent({
17+
name: 'slot-element',
18+
inheritAttrs: false,
19+
setup(props, { attrs, slots }) {
20+
return () => {
21+
const safeAttrs: Record<string, unknown> = {};
22+
23+
for (const [key, val] of Object.entries(attrs)) {
24+
const lowerKey = key.toLowerCase();
25+
26+
// Block event handlers
27+
if (lowerKey.startsWith('on')) {
28+
if (import.meta.env.DEV) {
29+
console.warn(`[SlotElement] Event handler "${key}" was blocked.`);
30+
}
31+
continue;
32+
}
33+
34+
// Only allow slot-relevant attributes
35+
if (!allowedSlotAttributes.has(lowerKey)) {
36+
if (import.meta.env.DEV) {
37+
console.warn(
38+
`[SlotElement] Irrelevant attribute "${key}" was blocked.`,
39+
);
40+
}
41+
continue;
42+
}
43+
44+
safeAttrs[key] = val;
45+
}
46+
47+
return h(
48+
'slot',
49+
safeAttrs,
50+
Object.keys(slots).length
51+
? Object.fromEntries(
52+
Object.entries(slots).map(([name, slotFn]) => [name, slotFn]),
53+
)
54+
: undefined,
55+
);
56+
};
57+
},
58+
});
59+
</script>

packages/jsonforms-vuetify-renderers/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export { default as ResolvedJsonForms } from './ResolvedJsonForms.vue';
22
export { default as DynamicElement } from './DynamicElement.vue';
3+
export { default as SlotElement } from './SlotElement.vue';
34
export { default as TemplateCompiler } from './TemplateCompiler.vue';
45
export const VMonacoEditor = () =>
56
import('./VMonacoEditor').then((m) => m.VMonacoEditor);

packages/jsonforms-vuetify-renderers/src/renderers/TemplateLayoutRenderer.vue

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ import { useFormContext } from '../util';
6767
6868
import { VDefaultsProvider } from 'vuetify/components/VDefaultsProvider';
6969
import * as defaultDirectives from 'vuetify/directives';
70-
import DynamicElement from '../components/DynamicElement.vue';
70+
import SlotElement from '../components/SlotElement.vue';
7171
import VPane from '../components/VPane.vue';
7272
import VSplitpanes from '../components/VSplitpanes.vue';
7373
@@ -206,7 +206,7 @@ const controlRenderer = defineComponent({
206206
...this.defaultComponents,
207207
VMonacoEditor,
208208
ValidationIcon,
209-
DynamicElement,
209+
SlotElement,
210210
VSplitpanes,
211211
VPane,
212212
...(override ? override : {}),

packages/jsonforms-vuetify-webcomponent/src/web-components/VuetifyJsonForms.ce.vue

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
{{ vuetifyThemeCss }}
1111
</dynamic-element>
1212

13-
<dynamic-element tag="slot" name="styles"> </dynamic-element>
13+
<slot-element name="styles"></slot-element>
1414

1515
<dynamic-element tag="style" type="text/css" :nonce="stylesheetNonce">
1616
{{ customStyleToUse }}
@@ -33,9 +33,9 @@
3333
</v-container>
3434

3535
<template v-else>
36-
<dynamic-element tag="slot" name="form-header">
36+
<slot-element name="form-header">
3737
<!-- Place custom content inside <div slot="form-header"></div> within <vuetify-json-forms> to fill this slot -->
38-
</dynamic-element>
38+
</slot-element>
3939

4040
<resolved-json-forms
4141
part="json-forms"
@@ -44,9 +44,9 @@
4444
@change="onChange"
4545
></resolved-json-forms>
4646

47-
<dynamic-element tag="slot" name="form-footer">
47+
<slot-element name="form-footer">
4848
<!-- Place custom content inside <div slot="form-footer"></div> within <vuetify-json-forms> to fill this slot -->
49-
</dynamic-element>
49+
</slot-element>
5050
</template>
5151
</v-sheet>
5252
</v-defaults-provider>
@@ -63,6 +63,7 @@ import { useAppStore } from '@/store';
6363
import {
6464
createTranslator,
6565
DynamicElement,
66+
SlotElement,
6667
extraVuetifyRenderers,
6768
type FormContext,
6869
FormContextKey,
@@ -160,6 +161,7 @@ export default defineComponent({
160161
VCol,
161162
VSheet,
162163
DynamicElement,
164+
SlotElement,
163165
},
164166
emits: ['change', 'handle-action'],
165167
props: {

0 commit comments

Comments
 (0)