362 lines
14 KiB
TypeScript
362 lines
14 KiB
TypeScript
// tslint:disable:no-any max-line-length
|
|
import {
|
|
BrandingElementType,
|
|
IBrandingElement,
|
|
IBrandingNormalizationResult,
|
|
ICssFile,
|
|
ICustomBrandingApplicationCustomizerProperties,
|
|
ICustomBrandingConfig,
|
|
IPortalSettingsDescriptor
|
|
} from './BrandingTypes';
|
|
|
|
export const BrandingSchemaVersion: number = 2;
|
|
export const MaxBrandingDepth: number = 8;
|
|
export const MaxBrandingElements: number = 200;
|
|
export const MaxBrandingConfigurationLength: number = 100000;
|
|
|
|
const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'form', 'input', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'];
|
|
const GlobalAttributes: string[] = ['id', 'class', 'title', 'role', 'aria-label', 'aria-hidden', 'aria-current', 'aria-live'];
|
|
const AllowedStyles: string[] = [
|
|
'align-items', 'background', 'background-color', 'border', 'border-bottom', 'border-color', 'border-left',
|
|
'border-radius', 'border-right', 'border-style', 'border-top', 'border-width', 'box-sizing', 'color',
|
|
'display', 'flex', 'flex-basis', 'flex-direction', 'flex-grow', 'flex-shrink', 'flex-wrap', 'font-family',
|
|
'font-size', 'font-style', 'font-weight', 'gap', 'grid-template-columns', 'height', 'justify-content',
|
|
'line-height', 'margin', 'margin-bottom', 'margin-left', 'margin-right', 'margin-top', 'max-height',
|
|
'max-width', 'min-height', 'min-width', 'opacity', 'overflow', 'padding', 'padding-bottom', 'padding-left',
|
|
'padding-right', 'padding-top', 'text-align', 'text-decoration', 'text-transform', 'white-space', 'width'
|
|
];
|
|
|
|
interface INormalizationState {
|
|
count: number;
|
|
warnings: string[];
|
|
siteCollectionUrl: string;
|
|
}
|
|
|
|
export function normalizeBrandingConfig(
|
|
rawProperties: ICustomBrandingApplicationCustomizerProperties | any,
|
|
siteCollectionUrl: string
|
|
): IBrandingNormalizationResult {
|
|
const warnings: string[] = [];
|
|
const raw: any = rawProperties && typeof rawProperties === 'object' ? rawProperties : {};
|
|
let serializedLength: number = 0;
|
|
try {
|
|
serializedLength = JSON.stringify(raw).length;
|
|
} catch (error) {
|
|
warnings.push('Configuration could not be serialized and was ignored.');
|
|
}
|
|
if (serializedLength > MaxBrandingConfigurationLength) {
|
|
warnings.push('Configuration exceeds the maximum size and was ignored.');
|
|
return { config: createEmptyConfig(siteCollectionUrl), warnings: warnings };
|
|
}
|
|
|
|
const allowedCssHosts: string[] = normalizeHosts(raw.allowedCssHosts);
|
|
const state: INormalizationState = { count: 0, warnings: warnings, siteCollectionUrl: siteCollectionUrl || '' };
|
|
const topSource: any[] = raw.placeholdertop && Array.isArray(raw.placeholdertop.elements)
|
|
? raw.placeholdertop.elements
|
|
: (Array.isArray(raw.elements) ? raw.elements : []);
|
|
const configuredBottomSource: any[] = raw.placeholderbottom && Array.isArray(raw.placeholderbottom.elements)
|
|
? raw.placeholderbottom.elements
|
|
: [];
|
|
const bottomSource: any[] = configuredBottomSource.length > 0
|
|
? configuredBottomSource
|
|
: createDefaultFooterElements();
|
|
|
|
const config: ICustomBrandingConfig = {
|
|
schemaVersion: BrandingSchemaVersion,
|
|
portalSettings: createPortalSettingsDescriptor(),
|
|
enabled: raw.enabled !== false,
|
|
debug: raw.debug === true,
|
|
allowedCssHosts: allowedCssHosts,
|
|
cssfiles: normalizeCssFiles(raw.cssfiles, siteCollectionUrl, allowedCssHosts, warnings),
|
|
placeholdertop: { elements: normalizeElements(topSource, 1, state) },
|
|
placeholderbottom: { elements: normalizeElements(bottomSource, 1, state) }
|
|
};
|
|
return { config: config, warnings: warnings };
|
|
}
|
|
|
|
export function sanitizeNavigationUrl(value: string, siteCollectionUrl: string, allowMailto: boolean): string | undefined {
|
|
const raw: string = String(value || '').trim();
|
|
if (!raw || /[\u0000-\u001f\u007f]/.test(raw)) {
|
|
return undefined;
|
|
}
|
|
const resolved: string = raw.toLowerCase().indexOf('~sitecollection') === 0
|
|
? String(siteCollectionUrl || '').replace(/\/+$/, '') + raw.substring('~sitecollection'.length)
|
|
: raw;
|
|
const protocolMatch: RegExpMatchArray | null = resolved.match(/^([a-z][a-z0-9+.-]*):/i);
|
|
if (!protocolMatch) {
|
|
return resolved;
|
|
}
|
|
const protocol: string = protocolMatch[1].toLowerCase();
|
|
return protocol === 'http' || protocol === 'https' || (allowMailto && protocol === 'mailto')
|
|
? resolved
|
|
: undefined;
|
|
}
|
|
|
|
export function sanitizeStylesheetUrl(
|
|
value: string,
|
|
siteCollectionUrl: string,
|
|
allowedCssHosts: string[]
|
|
): string | undefined {
|
|
const resolved: string = sanitizeNavigationUrl(value, siteCollectionUrl, false);
|
|
if (!resolved) {
|
|
return undefined;
|
|
}
|
|
const absolute: RegExpMatchArray | null = resolved.match(/^(https?):\/\/([^/]+)/i);
|
|
if (!absolute) {
|
|
return resolved;
|
|
}
|
|
const site: RegExpMatchArray | null = String(siteCollectionUrl || '').match(/^(https?):\/\/([^/]+)/i);
|
|
const protocol: string = absolute[1].toLowerCase();
|
|
const host: string = absolute[2].toLowerCase();
|
|
if (site && protocol === site[1].toLowerCase() && host === site[2].toLowerCase()) {
|
|
return resolved;
|
|
}
|
|
if (protocol !== 'https' || allowedCssHosts.indexOf(host) < 0) {
|
|
return undefined;
|
|
}
|
|
return resolved;
|
|
}
|
|
|
|
export function sanitizeStyle(propertyName: string, value: string): string | undefined {
|
|
const property: string = String(propertyName || '').trim().toLowerCase();
|
|
const styleValue: string = String(value || '').trim();
|
|
if (AllowedStyles.indexOf(property) < 0 || !styleValue || styleValue.length > 512) {
|
|
return undefined;
|
|
}
|
|
if (/[\u0000-\u001f\u007f]/.test(styleValue)
|
|
|| /(url\s*\(|expression\s*\(|javascript\s*:|@import|behavior\s*:|-moz-binding)/i.test(styleValue)) {
|
|
return undefined;
|
|
}
|
|
return styleValue;
|
|
}
|
|
|
|
function createEmptyConfig(siteCollectionUrl: string): ICustomBrandingConfig {
|
|
const state: INormalizationState = { count: 0, warnings: [], siteCollectionUrl: siteCollectionUrl || '' };
|
|
return {
|
|
schemaVersion: BrandingSchemaVersion,
|
|
portalSettings: createPortalSettingsDescriptor(),
|
|
enabled: true,
|
|
debug: false,
|
|
allowedCssHosts: [],
|
|
cssfiles: [],
|
|
placeholdertop: { elements: [] },
|
|
placeholderbottom: { elements: normalizeElements(createDefaultFooterElements(), 1, state) }
|
|
};
|
|
}
|
|
|
|
function createDefaultFooterElements(): any[] {
|
|
return [{
|
|
type: 'div',
|
|
attributes: { role: 'contentinfo', 'aria-label': 'Portalverwaltung' },
|
|
styles: {
|
|
padding: '8px 16px', 'text-align': 'right', width: '100%', 'box-sizing': 'border-box'
|
|
},
|
|
children: [{
|
|
type: 'a',
|
|
attributes: { href: '~sitecollection/SitePages/PortalSettings.aspx' },
|
|
children: [{
|
|
type: 'span',
|
|
attributes: { class: 'ms-Icon ms-Icon--Settings', 'aria-hidden': 'true' },
|
|
styles: { 'margin-right': '4px' }
|
|
}, {
|
|
type: 'span', content: 'Portal Settings'
|
|
}]
|
|
}]
|
|
}];
|
|
}
|
|
|
|
function createPortalSettingsDescriptor(): IPortalSettingsDescriptor {
|
|
return {
|
|
providerKey: 'custombranding',
|
|
contractVersion: 1,
|
|
minimumPortalSettingsVersion: '3.0.0'
|
|
};
|
|
}
|
|
|
|
function normalizeHosts(value: any): string[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
const result: string[] = [];
|
|
for (let i: number = 0; i < value.length; i++) {
|
|
const host: string = String(value[i] || '').trim().toLowerCase().replace(/^https?:\/\//, '').replace(/\/.*$/, '');
|
|
if (/^[a-z0-9.-]+(?::\d+)?$/.test(host) && result.indexOf(host) < 0) {
|
|
result.push(host);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeCssFiles(
|
|
value: any,
|
|
siteCollectionUrl: string,
|
|
allowedCssHosts: string[],
|
|
warnings: string[]
|
|
): ICssFile[] {
|
|
if (!Array.isArray(value)) {
|
|
return [];
|
|
}
|
|
const result: ICssFile[] = [];
|
|
const seen: string[] = [];
|
|
for (let i: number = 0; i < value.length && result.length < 20; i++) {
|
|
const item: any = value[i];
|
|
const path: string = sanitizeStylesheetUrl(item && item.path, siteCollectionUrl, allowedCssHosts);
|
|
if (!path) {
|
|
warnings.push('Stylesheet at index ' + i + ' was rejected.');
|
|
continue;
|
|
}
|
|
const normalizedKey: string = path.toLowerCase();
|
|
if (seen.indexOf(normalizedKey) >= 0) {
|
|
continue;
|
|
}
|
|
seen.push(normalizedKey);
|
|
const media: string = item && typeof item.media === 'string' && /^[a-z0-9 (),.:/-]{1,80}$/i.test(item.media)
|
|
? item.media.trim()
|
|
: 'all';
|
|
result.push({ path: path, media: media });
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeElements(value: any[], depth: number, state: INormalizationState): IBrandingElement[] {
|
|
const result: IBrandingElement[] = [];
|
|
if (!Array.isArray(value) || depth > MaxBrandingDepth) {
|
|
if (depth > MaxBrandingDepth) {
|
|
state.warnings.push('Maximum element depth exceeded.');
|
|
}
|
|
return result;
|
|
}
|
|
for (let i: number = 0; i < value.length && state.count < MaxBrandingElements; i++) {
|
|
const normalized: IBrandingElement = normalizeElement(value[i], depth, state);
|
|
if (normalized) {
|
|
result.push(normalized);
|
|
}
|
|
}
|
|
if (state.count >= MaxBrandingElements) {
|
|
state.warnings.push('Maximum element count reached.');
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function normalizeElement(value: any, depth: number, state: INormalizationState): IBrandingElement | undefined {
|
|
if (!value || typeof value !== 'object') {
|
|
state.warnings.push('Invalid element was ignored.');
|
|
return undefined;
|
|
}
|
|
const tag: string = String(value.type || '').trim().toLowerCase();
|
|
if (AllowedTags.indexOf(tag) < 0) {
|
|
state.warnings.push('Element type "' + tag + '" was rejected.');
|
|
return undefined;
|
|
}
|
|
state.count++;
|
|
const attributes: { [key: string]: string } = normalizeAttributes(tag, value.attributes, state);
|
|
if (tag === 'img' && attributes.alt === undefined) {
|
|
state.warnings.push('Image without alt attribute was rejected.');
|
|
return undefined;
|
|
}
|
|
if (tag === 'form' && !attributes.action) {
|
|
state.warnings.push('Form without a safe action URL was rejected.');
|
|
return undefined;
|
|
}
|
|
|
|
const styles: { [key: string]: string } = {};
|
|
if (value.styles && typeof value.styles === 'object') {
|
|
for (const styleName in value.styles) {
|
|
if (value.styles.hasOwnProperty(styleName)) {
|
|
const safeStyle: string = sanitizeStyle(styleName, value.styles[styleName]);
|
|
if (safeStyle) {
|
|
styles[styleName.toLowerCase()] = safeStyle;
|
|
} else {
|
|
state.warnings.push('Style "' + styleName + '" was rejected.');
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const content: string = value.content === undefined || value.content === null
|
|
? ''
|
|
: String(value.content).substring(0, 4000);
|
|
const children: IBrandingElement[] = tag === 'img' || tag === 'input'
|
|
? []
|
|
: normalizeElements(value.children, depth + 1, state);
|
|
if ((tag === 'a' || tag === 'button') && !content && children.length === 0 && !attributes['aria-label']) {
|
|
state.warnings.push('Empty interactive element was rejected.');
|
|
return undefined;
|
|
}
|
|
|
|
return {
|
|
type: tag as BrandingElementType,
|
|
content: content || undefined,
|
|
attributes: hasKeys(attributes) ? attributes : undefined,
|
|
styles: hasKeys(styles) ? styles : undefined,
|
|
children: children.length > 0 ? children : undefined
|
|
};
|
|
}
|
|
|
|
function normalizeAttributes(tag: string, value: any, state: INormalizationState): { [key: string]: string } {
|
|
const result: { [key: string]: string } = {};
|
|
if (!value || typeof value !== 'object') {
|
|
if (tag === 'button') { result.type = 'button'; }
|
|
if (tag === 'form') { result.method = 'get'; }
|
|
if (tag === 'input') { result.type = 'search'; result.name = 'q'; }
|
|
return result;
|
|
}
|
|
for (const rawName in value) {
|
|
if (!value.hasOwnProperty(rawName)) { continue; }
|
|
const name: string = String(rawName || '').trim().toLowerCase();
|
|
const rawValue: string = String(value[rawName] === undefined ? '' : value[rawName]).substring(0, 2048);
|
|
if (name.indexOf('on') === 0 || !isAttributeAllowed(tag, name)) {
|
|
state.warnings.push('Attribute "' + name + '" was rejected.');
|
|
continue;
|
|
}
|
|
if (name === 'href' || name === 'src' || name === 'action') {
|
|
const safeUrl: string = sanitizeNavigationUrl(rawValue, state.siteCollectionUrl, name === 'href');
|
|
if (safeUrl && safeUrl.indexOf('//') !== 0) { result[name] = safeUrl; } else { state.warnings.push('URL attribute was rejected.'); }
|
|
} else if (name === 'target') {
|
|
if (rawValue === '_blank' || rawValue === '_self') { result[name] = rawValue; }
|
|
} else if (name === 'id' || name === 'class') {
|
|
if (/^[a-z0-9 _-]{1,256}$/i.test(rawValue)) { result[name] = rawValue; }
|
|
} else if (name === 'width' || name === 'height') {
|
|
if (/^\d{1,4}$/.test(rawValue)) { result[name] = rawValue; }
|
|
} else if (name === 'aria-hidden') {
|
|
if (rawValue === 'true' || rawValue === 'false') { result[name] = rawValue; }
|
|
} else if (name === 'type' && tag === 'button') {
|
|
result.type = rawValue.toLowerCase() === 'submit' ? 'submit' : 'button';
|
|
} else if (name === 'type' && tag === 'input') {
|
|
result.type = 'search';
|
|
} else if (name === 'method' && tag === 'form') {
|
|
result.method = 'get';
|
|
} else if (name === 'name' && tag === 'input') {
|
|
if (/^[a-z0-9_-]{1,64}$/i.test(rawValue)) { result.name = rawValue; }
|
|
} else if (name === 'autocomplete' && tag === 'input') {
|
|
if (rawValue === 'on' || rawValue === 'off') { result.autocomplete = rawValue; }
|
|
} else if (name === 'maxlength' && tag === 'input') {
|
|
if (/^\d{1,4}$/.test(rawValue)) { result.maxlength = rawValue; }
|
|
} else {
|
|
result[name] = rawValue;
|
|
}
|
|
}
|
|
if (tag === 'button' && result.type !== 'submit') { result.type = 'button'; }
|
|
if (tag === 'form') { result.method = 'get'; }
|
|
if (tag === 'input') { result.type = 'search'; result.name = result.name || 'q'; }
|
|
if (tag === 'a' && result.target === '_blank') { result.rel = 'noopener noreferrer'; }
|
|
return result;
|
|
}
|
|
|
|
function isAttributeAllowed(tag: string, name: string): boolean {
|
|
if (GlobalAttributes.indexOf(name) >= 0) { return true; }
|
|
if (tag === 'a') { return ['href', 'target'].indexOf(name) >= 0; }
|
|
if (tag === 'img') { return ['src', 'alt', 'width', 'height'].indexOf(name) >= 0; }
|
|
if (tag === 'button') { return ['type', 'disabled', 'aria-expanded', 'aria-controls'].indexOf(name) >= 0; }
|
|
if (tag === 'form') { return ['action', 'method'].indexOf(name) >= 0; }
|
|
if (tag === 'input') { return ['type', 'name', 'placeholder', 'autocomplete', 'maxlength'].indexOf(name) >= 0; }
|
|
return false;
|
|
}
|
|
|
|
function hasKeys(value: { [key: string]: string }): boolean {
|
|
for (const key in value) {
|
|
if (value.hasOwnProperty(key)) { return true; }
|
|
}
|
|
return false;
|
|
}
|