Options always shown below the group no matter how the list is sorted
XSS protection for tables and trees
This commit is contained in:
parent
e230510b72
commit
d9211377dd
6 changed files with 96 additions and 21 deletions
|
@ -239,8 +239,21 @@ class Table extends Component {
|
|||
type: 'html',
|
||||
createdCell: createdCellFn
|
||||
});
|
||||
}
|
||||
|
||||
// FIXME, sift all columns through renderToStaticMarkup in order to sanitize the HTML
|
||||
// XSS protection
|
||||
for (const column of columns) {
|
||||
const originalRender = column.render;
|
||||
column.render = (data, ...rest) => {
|
||||
if (originalRender) {
|
||||
const markup = originalRender(data, ...rest);
|
||||
return ReactDOMServer.renderToStaticMarkup(<div>{markup}</div>);
|
||||
} else {
|
||||
return ReactDOMServer.renderToStaticMarkup(<div>{data}</div>)
|
||||
}
|
||||
};
|
||||
|
||||
column.title = ReactDOMServer.renderToStaticMarkup(<div>{column.title}</div>);
|
||||
}
|
||||
|
||||
const dtOptions = {
|
||||
|
|
|
@ -86,6 +86,17 @@ class TreeTable extends Component {
|
|||
return this.props.selection !== nextProps.selection || this.state.treeData != nextState.treeData;
|
||||
}
|
||||
|
||||
// XSS protection
|
||||
sanitizeTreeData(unsafeData) {
|
||||
const data = unsafeData.slice();
|
||||
for (const entry of data) {
|
||||
entry.title = ReactDOMServer.renderToStaticMarkup(<div>{entry.title}</div>)
|
||||
entry.description = ReactDOMServer.renderToStaticMarkup(<div>{entry.description}</div>)
|
||||
entry.children = this.sanitizeTreeData(entry.children);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (!this.props.data && this.props.dataUrl) {
|
||||
this.loadData(this.props.dataUrl);
|
||||
|
@ -109,10 +120,8 @@ class TreeTable extends Component {
|
|||
|
||||
let tdIdx = 1;
|
||||
|
||||
// FIXME, sift title through renderToStaticMarkup in order to sanitize the HTML
|
||||
|
||||
if (this.props.withDescription) {
|
||||
const descHtml = ReactDOMServer.renderToStaticMarkup(<div>{node.data.description}</div>);
|
||||
const descHtml = node.data.description; // This was already sanitized in sanitizeTreeData when the data was loaded
|
||||
tdList.eq(tdIdx).html(descHtml);
|
||||
tdIdx += 1;
|
||||
}
|
||||
|
@ -142,7 +151,7 @@ class TreeTable extends Component {
|
|||
icon: false,
|
||||
autoScroll: true,
|
||||
scrollParent: jQuery(this.domTableContainer),
|
||||
source: this.state.treeData,
|
||||
source: this.sanitizeTreeData(this.state.treeData),
|
||||
table: {
|
||||
nodeColumnIdx: 0
|
||||
},
|
||||
|
@ -156,7 +165,7 @@ class TreeTable extends Component {
|
|||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.tree.reload(this.state.treeData);
|
||||
this.tree.reload(this.sanitizeTreeData(this.state.treeData));
|
||||
this.updateSelection();
|
||||
}
|
||||
|
||||
|
|
|
@ -71,7 +71,7 @@ export default class List extends Component {
|
|||
|
||||
const columns = [
|
||||
{ data: 1, title: t('Name') },
|
||||
{ data: 2, title: t('ID'), render: data => `<code>${data}</code>` },
|
||||
{ data: 2, title: t('ID'), render: data => <code>{data}</code> },
|
||||
{ data: 3, title: t('Subscribers') },
|
||||
{ data: 4, title: t('Description') },
|
||||
{ data: 5, title: t('Namespace') }
|
||||
|
|
|
@ -67,7 +67,7 @@ export default class CUD extends Component {
|
|||
const getOrderOptions = fld => {
|
||||
return [
|
||||
{key: 'none', label: t('Not visible')},
|
||||
...flds.data.filter(x => (!this.props.entity || x.id !== this.props.entity.id) && x[fld] !== null).sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id, label: `${x.name} (${this.fieldTypes[x.type].label})`})),
|
||||
...flds.data.filter(x => (!this.props.entity || x.id !== this.props.entity.id) && x[fld] !== null && x.type !== 'option').sort((x, y) => x[fld] - y[fld]).map(x => ({ key: x.id.toString(), label: `${x.name} (${this.fieldTypes[x.type].label})`})),
|
||||
{key: 'end', label: t('End of list')}
|
||||
];
|
||||
};
|
||||
|
@ -82,6 +82,8 @@ export default class CUD extends Component {
|
|||
componentDidMount() {
|
||||
if (this.props.entity) {
|
||||
this.getFormValuesFromEntity(this.props.entity, data => {
|
||||
data.settings = data.settings || {};
|
||||
|
||||
if (data.default_value === null) {
|
||||
data.default_value = '';
|
||||
}
|
||||
|
@ -113,6 +115,10 @@ export default class CUD extends Component {
|
|||
data.dateFormat = data.settings.dateFormat;
|
||||
break;
|
||||
}
|
||||
|
||||
data.orderListBefore = data.orderListBefore.toString();
|
||||
data.orderSubscribeBefore = data.orderSubscribeBefore.toString();
|
||||
data.orderManageBefore = data.orderManageBefore.toString();
|
||||
});
|
||||
|
||||
} else {
|
||||
|
@ -177,9 +183,17 @@ export default class CUD extends Component {
|
|||
state.setIn(['default_value', 'error'], null);
|
||||
}
|
||||
|
||||
let enumOptionErrors;
|
||||
if ((type === 'radio-enum' || type === 'dropdown-enum') && (enumOptionErrors = this.parseEnumOptions(state.getIn(['enumOptions', 'value'])).errors)) {
|
||||
state.setIn(['enumOptions', 'error'], <div>{enumOptionErrors.map((err, idx) => <div key={idx}>{err}</div>)}</div>);
|
||||
if (type === 'radio-enum' || type === 'dropdown-enum') {
|
||||
const enumOptions = this.parseEnumOptions(state.getIn(['enumOptions', 'value']));
|
||||
if (enumOptions.errors) {
|
||||
state.setIn(['enumOptions', 'error'], <div>{enumOptions.errors.map((err, idx) => <div key={idx}>{err}</div>)}</div>);
|
||||
} else {
|
||||
state.setIn(['enumOptions', 'error'], null);
|
||||
|
||||
if (defaultValue !== '' && !(defaultValue in enumOptions.options)) {
|
||||
state.setIn(['default_value', 'error'], t('Default value is not one of the allowed options'));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
state.setIn(['enumOptions', 'error'], null);
|
||||
}
|
||||
|
@ -271,6 +285,14 @@ export default class CUD extends Component {
|
|||
delete data.renderTemplate;
|
||||
delete data.enumOptions;
|
||||
delete data.dateFormat;
|
||||
|
||||
if (data.type === 'option') {
|
||||
data.orderListBefore = data.orderSubscribeBefore = data.orderManageBefore = 'none';
|
||||
} else {
|
||||
data.orderListBefore = Number.parseInt(data.orderListBefore) || data.orderListBefore;
|
||||
data.orderSubscribeBefore = Number.parseInt(data.orderSubscribeBefore) || data.orderSubscribeBefore;
|
||||
data.orderManageBefore = Number.parseInt(data.orderManageBefore) || data.orderManageBefore;
|
||||
}
|
||||
});
|
||||
|
||||
if (submitSuccessful) {
|
||||
|
@ -445,11 +467,13 @@ export default class CUD extends Component {
|
|||
|
||||
{fieldSettings}
|
||||
|
||||
<Fieldset label={t('Field order')}>
|
||||
<Dropdown id="orderListBefore" label={t('Listings (before)')} options={this.state.orderListOptions} help={t('Select the field before which this field should appeara in listings. To exclude the field from listings, select "Not visible".')}/>
|
||||
<Dropdown id="orderSubscribeBefore" label={t('Subscription form (before)')} options={this.state.orderSubscribeOptions} help={t('Select the field before which this field should appear in new subscription form. To exclude the field from the new subscription form, select "Not visible".')}/>
|
||||
<Dropdown id="orderManageBefore" label={t('Management form (before)')} options={this.state.orderManageOptions} help={t('Select the field before which this field should appear in subscription management. To exclude the field from the subscription management form, select "Not visible".')}/>
|
||||
</Fieldset>
|
||||
{type !== 'option' &&
|
||||
<Fieldset label={t('Field order')}>
|
||||
<Dropdown id="orderListBefore" label={t('Listings (before)')} options={this.state.orderListOptions} help={t('Select the field before which this field should appeara in listings. To exclude the field from listings, select "Not visible".')}/>
|
||||
<Dropdown id="orderSubscribeBefore" label={t('Subscription form (before)')} options={this.state.orderSubscribeOptions} help={t('Select the field before which this field should appear in new subscription form. To exclude the field from the new subscription form, select "Not visible".')}/>
|
||||
<Dropdown id="orderManageBefore" label={t('Management form (before)')} options={this.state.orderManageOptions} help={t('Select the field before which this field should appear in subscription management. To exclude the field from the subscription management form, select "Not visible".')}/>
|
||||
</Fieldset>
|
||||
}
|
||||
|
||||
<ButtonRow>
|
||||
<Button type="submit" className="btn-primary" icon="ok" label={t('Save')}/>
|
||||
|
|
|
@ -34,7 +34,9 @@ export default class List extends Component {
|
|||
|
||||
const columns = [
|
||||
{ data: 4, title: "#" },
|
||||
{ data: 1, title: t('Name') },
|
||||
{ data: 1, title: t('Name'),
|
||||
render: (data, cmd, rowData) => rowData[2] === 'option' ? <span><span className="glyphicon glyphicon-record" aria-hidden="true"></span> {data}</span> : data
|
||||
},
|
||||
{ data: 2, title: t('Type'), render: data => this.fieldTypes[data].label, sortable: false, searchable: false },
|
||||
{ data: 3, title: t('Merge Tag') }
|
||||
];
|
||||
|
|
|
@ -137,14 +137,34 @@ async function listDTAjax(context, listId, params) {
|
|||
builder => builder
|
||||
.from('custom_fields')
|
||||
.innerJoin('lists', 'custom_fields.list', 'lists.id')
|
||||
|
||||
// This self join is to provide 'option' fields a reference to their parent grouped field. If the field is not an option, it refers to itself
|
||||
// All this is to show options always below their group parent
|
||||
.innerJoin('custom_fields AS parent_fields', function() {
|
||||
this.on(function() {
|
||||
this.on('custom_fields.type', '=', knex.raw('?', ['option']))
|
||||
.on('custom_fields.group', '=', 'parent_fields.id');
|
||||
}).orOn(function() {
|
||||
this.on('custom_fields.type', '<>', knex.raw('?', ['option']))
|
||||
.on('custom_fields.id', '=', 'parent_fields.id');
|
||||
});
|
||||
})
|
||||
.where('custom_fields.list', listId),
|
||||
[ 'custom_fields.id', 'custom_fields.name', 'custom_fields.type', 'custom_fields.key', 'custom_fields.order_list' ],
|
||||
{
|
||||
orderByBuilder: (builder, orderColumn, orderDir) => {
|
||||
// We use here parent_fields to keep options always below their parent group
|
||||
if (orderColumn === 'custom_fields.order_list') {
|
||||
builder.orderBy(knex.raw('-custom_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc'); // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last
|
||||
builder
|
||||
.orderBy(knex.raw('-parent_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc') // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last
|
||||
.orderBy('parent_fields.name', orderDir)
|
||||
.orderBy(knex.raw('custom_fields.type = "option"'), 'asc')
|
||||
} else {
|
||||
builder.orderBy(orderColumn, orderDir);
|
||||
const parentColumn = orderColumn.replace(/^custom_fields/, 'parent_fields');
|
||||
builder
|
||||
.orderBy(parentColumn, orderDir)
|
||||
.orderBy('parent_fields.name', orderDir)
|
||||
.orderBy(knex.raw('custom_fields.type = "option"'), 'asc');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -165,9 +185,13 @@ async function listGroupedDTAjax(context, listId, params) {
|
|||
{
|
||||
orderByBuilder: (builder, orderColumn, orderDir) => {
|
||||
if (orderColumn === 'custom_fields.order_list') {
|
||||
builder.orderBy(knex.raw('-custom_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc'); // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last
|
||||
builder
|
||||
.orderBy(knex.raw('-custom_fields.order_list'), orderDir === 'asc' ? 'desc' : 'asc') // This is MySQL speciality. It sorts the rows in ascending order with NULL values coming last
|
||||
.orderBy('custom_fields.name', orderDir);
|
||||
} else {
|
||||
builder.orderBy(orderColumn, orderDir);
|
||||
builder
|
||||
.orderBy(orderColumn, orderDir)
|
||||
.orderBy('custom_fields.name', orderDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -203,6 +227,8 @@ async function serverValidate(context, listId, data) {
|
|||
async function _validateAndPreprocess(tx, listId, entity, isCreate) {
|
||||
enforce(entity.type === 'option' || !entity.group, 'Only option may have a group assigned');
|
||||
enforce(entity.type !== 'option' || entity.group, 'Option must have a group assigned.');
|
||||
enforce(entity.type !== 'option' || (entity.orderListBefore === 'none' && entity.orderSubscribeBefore === 'none' && entity.orderManageBefore === 'none'), 'Option cannot be made visible');
|
||||
|
||||
enforce(!entity.group || await tx('custom_fields').where({list: listId, id: entity.group}).first(), 'Group field does not exist');
|
||||
enforce(entity.name, 'Name must be present');
|
||||
|
||||
|
@ -298,6 +324,7 @@ async function create(context, listId, entity) {
|
|||
const ids = await tx('custom_fields').insert(filteredEntity);
|
||||
id = ids[0];
|
||||
|
||||
|
||||
await _sortIn(tx, listId, id, entity.orderListBefore, entity.orderSubscribeBefore, entity.orderManageBefore);
|
||||
|
||||
if (columnName) {
|
||||
|
|
Loading…
Reference in a new issue