From 281dfa68de3d018ca23e726ae9917aca9ce7fa44 Mon Sep 17 00:00:00 2001 From: Torsten Brendgen Date: Wed, 19 Aug 2026 23:33:56 +0200 Subject: [PATCH] feat: Update version to 3.0.5 and enhance CustomBranding with new search form functionality and improved styling --- README.md | 6 +- ToDo.md | 2 +- classic/custom-branding-classic.js | 29 ++- config/package-solution.json | 2 +- examples/custom-branding.css | 226 ++++++++++++++++-- examples/custom-branding.example.json | 79 +++++- package-lock.json | 2 +- package.json | 2 +- .../customBranding/BrandingConfig.ts | 32 ++- .../customBranding/BrandingTypes.ts | 2 +- .../CustomBrandingApplicationCustomizer.ts | 2 +- tests/BrandingConfig.test.js | 22 +- tests/validate-static-assets.js | 7 +- 13 files changed, 353 insertions(+), 60 deletions(-) diff --git a/README.md b/README.md index 1958beb..220f0d3 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # CustomBranding -CustomBranding 3.0.4 ist ein zentraler SPFx-1.4.1-Application-Customizer für SharePoint Server Subscription Edition. Die Solution lädt freigegebene Stylesheets und rendert eine kontrollierte Komponentenstruktur im oberen oder unteren SharePoint-Placeholder. Moderne und klassische Seiten verwenden dieselben `ClientSideComponentProperties`. +CustomBranding 3.0.5 ist ein zentraler SPFx-1.4.1-Application-Customizer für SharePoint Server Subscription Edition. Die Solution lädt freigegebene Stylesheets und rendert eine kontrollierte Komponentenstruktur im oberen oder unteren SharePoint-Placeholder. Moderne und klassische Seiten verwenden dieselben `ClientSideComponentProperties`. Die Konfiguration liegt in genau einer `SPSite.UserCustomAction` pro Site Collection. Es werden weder eine versteckte Liste noch ein Property Bag benötigt. Dadurch gilt das Branding automatisch für das Root Web, vorhandene Subwebs und später angelegte Subwebs. @@ -121,6 +121,10 @@ Das aktuelle Konfigurationsschema hat die Version 2: Bestehende 1.x-Konfigurationen mit einem Root-Array `elements` werden weiterhin als Top-Inhalt gelesen. Unbekannte Properties ignoriert die Runtime. Das vollständige Beispiel liegt unter `examples/custom-branding.example.json`. +Eine eigene Suche kann deklarativ als `form` mit einem `input type="search"` und einem Submit-Button unter +`placeholdertop.elements` konfiguriert werden. Formulare werden ausschließlich als GET-Formulare akzeptiert; +unsichere oder protokollrelative Action-URLs werden verworfen. Das vollständige Beispiel enthält eine solche Suche. + Ist `placeholderbottom.elements` leer oder fehlt die Eigenschaft, rendert CustomBranding automatisch den bisherigen **Portal Settings**-Link auf `~sitecollection/SitePages/PortalSettings.aspx`. Sobald eigene Footer-Elemente konfiguriert sind, ersetzen sie diesen Standard. diff --git a/ToDo.md b/ToDo.md index ec7a7b9..0e00315 100644 --- a/ToDo.md +++ b/ToDo.md @@ -3,7 +3,7 @@ Analysestand: 20.07.2026 Umgesetzt: 20.07.2026 Ausgangsversion: 1.0.4 -Zielversion: 3.0.4 +Zielversion: 3.0.5 Status: Implementierung und lokale Qualitätssicherung abgeschlossen ## Zielbild diff --git a/classic/custom-branding-classic.js b/classic/custom-branding-classic.js index 3d15a09..8f33e29 100644 --- a/classic/custom-branding-classic.js +++ b/classic/custom-branding-classic.js @@ -1,4 +1,4 @@ -/* CustomBranding 2.0.0 - safe Classic SharePoint runtime */ +/* CustomBranding 3.0.5 - safe Classic SharePoint runtime */ (function (global) { 'use strict'; @@ -6,7 +6,7 @@ var OWNER = 'CustomBranding.Classic'; var MAX_DEPTH = 8; var MAX_ELEMENTS = 200; - var allowedTags = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']; + var allowedTags = ['div', 'span', 'p', 'a', 'button', 'form', 'input', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']; var allowedStyles = ('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').split(' '); var state = { debug: false, hosts: [], css: [] }; @@ -78,6 +78,8 @@ 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; } @@ -94,11 +96,23 @@ var name = String(rawName).toLowerCase(); var value = String(attributes[rawName] === undefined ? '' : attributes[rawName]).substring(0, 2048); if (name.indexOf('on') === 0 || !allowedAttribute(tag, name)) { continue; } - if (name === 'href' || name === 'src') { + if (name === 'href' || name === 'src' || name === 'action') { var safeUrl = sanitizeUrl(value, name === 'href'); - if (safeUrl) { element.setAttribute(name, safeUrl); } + if (safeUrl && safeUrl.indexOf('//') !== 0) { element.setAttribute(name, safeUrl); } } else if (name === 'target') { if (value === '_blank' || value === '_self') { element.setAttribute(name, value); } + } else if (name === 'type' && tag === 'button') { + element.setAttribute('type', value.toLowerCase() === 'submit' ? 'submit' : 'button'); + } else if (name === 'type' && tag === 'input') { + element.setAttribute('type', 'search'); + } else if (name === 'method' && tag === 'form') { + element.setAttribute('method', 'get'); + } else if (name === 'name' && tag === 'input') { + if (/^[a-z0-9_-]{1,64}$/i.test(value)) { element.setAttribute('name', value); } + } else if (name === 'autocomplete' && tag === 'input') { + if (value === 'on' || value === 'off') { element.setAttribute('autocomplete', value); } + } else if (name === 'maxlength' && tag === 'input') { + if (/^\d{1,4}$/.test(value)) { element.setAttribute('maxlength', value); } } else if ((name === 'id' || name === 'class') && !/^[a-z0-9 _-]{1,256}$/i.test(value)) { continue; } else if ((name === 'width' || name === 'height') && !/^\d{1,4}$/.test(value)) { @@ -109,7 +123,10 @@ if (name === 'alt') { hasAlt = true; } } if (tag === 'img' && !hasAlt) { return null; } - if (tag === 'button') { element.setAttribute('type', 'button'); } + if (tag === 'form' && !element.getAttribute('action')) { return null; } + if (tag === 'form') { element.setAttribute('method', 'get'); } + if (tag === 'input') { element.setAttribute('type', 'search'); element.setAttribute('name', element.getAttribute('name') || 'q'); } + if (tag === 'button' && element.getAttribute('type') !== 'submit') { element.setAttribute('type', 'button'); } if (tag === 'a' && element.getAttribute('target') === '_blank') { element.setAttribute('rel', 'noopener noreferrer'); } var styles = config.styles && typeof config.styles === 'object' ? config.styles : {}; @@ -121,7 +138,7 @@ if (config.content !== undefined && config.content !== null) { element.appendChild(document.createTextNode(String(config.content).substring(0, 4000))); } - if (tag !== 'img' && Array.isArray(config.children)) { + if (tag !== 'img' && tag !== 'input' && Array.isArray(config.children)) { for (var i = 0; i < config.children.length; i++) { var child = createElement(config.children[i], depth + 1, counter); if (child) { element.appendChild(child); } diff --git a/config/package-solution.json b/config/package-solution.json index 7b252fc..46c5d5f 100644 --- a/config/package-solution.json +++ b/config/package-solution.json @@ -3,7 +3,7 @@ "solution": { "name": "custom-branding-client-side-solution", "id": "03a7c4de-e031-4b83-a683-5ca5c364166e", - "version": "3.0.4.0", + "version": "3.0.5.0", "includeClientSideAssets": true, "skipFeatureDeployment": true }, diff --git a/examples/custom-branding.css b/examples/custom-branding.css index 6e85290..74fcfa0 100644 --- a/examples/custom-branding.css +++ b/examples/custom-branding.css @@ -1,44 +1,218 @@ -.custom-branding-banner { - align-items: center; - background: #f3f2f1; +/* + * Custom Branding und Mega Menu sind direkte Geschwister in #CustomHeader: + * + * #CustomHeader + * .system-ndzk-bar + * .portal-branding | #CustomNavigation + */ + +#CustomHeader { + align-items: stretch; + background: #ffffff; border-bottom: 1px solid #edebe9; box-sizing: border-box; - color: #323130; - display: flex; + display: grid; font-family: "Segoe UI", "Segoe UI Web (West European)", sans-serif; - font-size: 14px; - gap: 16px; - min-height: 40px; - padding: 8px 24px; + grid-template-areas: + "system system" + "branding navigation"; + grid-template-columns: 240px minmax(0, 1fr); + grid-template-rows: 28px 309px; + gap: 0; + height: 250px; + width: 100%; } -.custom-branding-link { +#CustomHeader > .system-ndzk-bar { + align-items: center; + background: #c50f1f; + box-sizing: border-box; + color: #ffffff; + display: flex; + font-size: 14px; + font-weight: 600; + grid-area: system; + grid-column: 1 / -1; + grid-row: 1; + line-height: 20px; + padding: 4px 16px; +} + +#CustomHeader > .portal-branding { + grid-area: branding; + grid-column: 1; + grid-row: 2; + min-width: 0; +} + +#CustomHeader > #CustomNavigation { + align-items: stretch; + align-self: start; + border-bottom: 0 !important; + border-top: 0 !important; + display: flex; + grid-area: navigation; + grid-column: 2; + grid-row: 2; + height: 40px; + min-width: 0; + position: relative; + --megaMenuBarHeight: 40px; +} + +#CustomHeader > #CustomNavigation > * { + width: 100%; +} + +#CustomHeader > #CustomNavigation .mega-menu-main { + background: #003d79; + border-top: 0 !important; + color: #ffffff; + height: 40px; + margin-top: 0; + min-height: 40px; +} + +#CustomHeader > #CustomNavigation .mega-menu-top-level, +#CustomHeader > #CustomNavigation .menu-item-link, +#CustomHeader > #CustomNavigation .menu-item-text, +#CustomHeader > #CustomNavigation .menu-item-toggle { + color: #ffffff; +} + +#CustomHeader > #CustomNavigation .mega-menu-top-item:hover, +#CustomHeader > #CustomNavigation .mega-menu-top-item:focus-within, +#CustomHeader > #CustomNavigation .mega-menu-top-item:active, +#CustomHeader > #CustomNavigation .mega-menu-top-item.is-open, +#CustomHeader > #CustomNavigation .mega-menu-top-item.is-active { + background: #0f548c; + color: #ffffff; +} + +/* Das geöffnete Mega Menu nutzt auch die 240-px-Fläche unterhalb des Logos. */ +#CustomHeader > #CustomNavigation .mega-menu { + left: -240px; +} + +#CustomHeader > #CustomNavigation .mega-menu-grid { + max-width: none; + width: 100%; +} + +/* Eigene Suche im freien Bereich rechts neben dem Logo. */ +#CustomHeader > #CustomBrandingSearch { + align-self: start; + grid-column: 2; + grid-row: 2; + justify-self: end; + margin: 64px 2px 0 0; + width: 272px; + z-index: 2; +} + +.custom-branding-search-form { + align-items: stretch; + background: #ffffff; + border: 1px solid #8a8886; + box-sizing: border-box; + display: flex; + height: 36px; + width: 100%; +} + +.custom-branding-search-form:focus-within { + border-color: #605e5c; + box-shadow: inset 0 0 0 1px #605e5c; +} + +.custom-branding-search-input { + background: transparent; + border: 0; + box-sizing: border-box; + color: #323130; + flex: 1 1 auto; + font-family: inherit; + font-size: 14px; + min-width: 0; + outline: 0; + padding: 0 12px; +} + +.custom-branding-search-input::placeholder { + color: #605e5c; + opacity: 1; +} + +.custom-branding-search-button { + align-items: center; + background: transparent; + border: 0; color: #005a9e; + cursor: pointer; + display: flex; + flex: 0 0 40px; + font-size: 16px; + justify-content: center; + padding: 0; +} + +.custom-branding-search-button:hover, +.custom-branding-search-button:focus { + background: #f3f2f1; + color: #004578; + outline: 0; +} + +.custom-branding-search-button:focus-visible { + box-shadow: inset 0 0 0 2px #605e5c; +} + +.ms-compositeHeader .ms-compositeHeader-searchBoxContainer { + display: none !important; +} + +.portal-branding { + align-items: flex-start; + border: 0; + box-sizing: border-box; + display: flex; + height: 220px; + justify-content: flex-start; + padding: 0; + width: 240px; +} + +.portal-branding-home { + align-items: flex-start; + color: #323130; + display: block; + width: 240px; text-decoration: none; } -.custom-branding-link:hover { - color: #004578; - text-decoration: underline; +.portal-branding-home:hover { + color: #201f1e; + text-decoration: none; } -.custom-branding-link:focus-visible, -.custom-branding-banner button:focus-visible { - outline: 2px solid #005a9e; - outline-offset: 2px; +.portal-branding-home:focus-visible { + outline: 2px solid #605e5c; + outline-offset: 3px; } -@media screen and (max-width: 640px) { - .custom-branding-banner { - align-items: flex-start; - flex-direction: column; - gap: 4px; - padding: 8px 12px; - } +.portal-branding-logo { + display: block; + height: 220px; + object-fit: contain; + width: 240px; + background-image: url(http://portal.contoso.local/Branding/Images/logo-background.png); + background-repeat: no-repeat; } @media (forced-colors: active) { - .custom-branding-banner { - border-bottom: 1px solid CanvasText; + #CustomHeader, + .portal-branding, + .system-ndzk-bar { + border-color: CanvasText; } } diff --git a/examples/custom-branding.example.json b/examples/custom-branding.example.json index 00fd266..8ce4a03 100644 --- a/examples/custom-branding.example.json +++ b/examples/custom-branding.example.json @@ -17,24 +17,81 @@ "placeholdertop": { "elements": [ { - "type": "section", + "type": "div", + "content": "System NDzK", "attributes": { - "class": "custom-branding-banner", - "role": "region", - "aria-label": "Portalhinweis" + "class": "system-ndzk-bar", + "role": "note" + } + }, + { + "type": "div", + "attributes": { + "class": "portal-branding", + "role": "banner", + "aria-label": "Portal-Kopfbereich" }, "children": [ { - "type": "strong", - "content": "Willkommen im Portal" + "type": "a", + "attributes": { + "class": "portal-branding-home", + "href": "~sitecollection/SitePages/Home.aspx", + "title": "Zur Portal-Startseite" + }, + "children": [ + { + "type": "img", + "attributes": { + "class": "portal-branding-logo", + "src": "~sitecollection/SiteAssets/branding/logo.png", + "alt": "Zentrale Abbildende Aufklärung" + } + } + ] + } + ] + }, + { + "type": "form", + "attributes": { + "id": "CustomBrandingSearch", + "class": "custom-branding-search-form", + "role": "search", + "aria-label": "Diese Website durchsuchen", + "action": "~sitecollection/_layouts/15/search.aspx/siteall", + "method": "get" + }, + "children": [ + { + "type": "input", + "attributes": { + "class": "custom-branding-search-input", + "type": "search", + "name": "q", + "placeholder": "Diese Website durchsuchen", + "autocomplete": "off", + "maxlength": "500", + "aria-label": "Diese Website durchsuchen" + } }, { - "type": "a", - "content": "Zur Startseite", + "type": "button", "attributes": { - "href": "~sitecollection/SitePages/Home.aspx", - "class": "custom-branding-link" - } + "class": "custom-branding-search-button", + "type": "submit", + "title": "Suchen", + "aria-label": "Suchen" + }, + "children": [ + { + "type": "span", + "attributes": { + "class": "ms-Icon ms-Icon--Search", + "aria-hidden": "true" + } + } + ] } ] } diff --git a/package-lock.json b/package-lock.json index f1d35db..6adf7fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,6 +1,6 @@ { "name": "custom-branding", - "version": "3.0.4", + "version": "3.0.5", "lockfileVersion": 1, "requires": true, "dependencies": { diff --git a/package.json b/package.json index bae486b..1f9d880 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "custom-branding", - "version": "3.0.4", + "version": "3.0.5", "private": true, "main": "lib/index.js", "engines": { diff --git a/src/extensions/customBranding/BrandingConfig.ts b/src/extensions/customBranding/BrandingConfig.ts index 14c0d47..4b3aa71 100644 --- a/src/extensions/customBranding/BrandingConfig.ts +++ b/src/extensions/customBranding/BrandingConfig.ts @@ -14,7 +14,7 @@ export const MaxBrandingDepth: number = 8; export const MaxBrandingElements: number = 200; export const MaxBrandingConfigurationLength: number = 100000; -const AllowedTags: string[] = ['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section']; +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', @@ -254,6 +254,10 @@ function normalizeElement(value: any, depth: number, state: INormalizationState) 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') { @@ -272,7 +276,7 @@ function normalizeElement(value: any, depth: number, state: INormalizationState) const content: string = value.content === undefined || value.content === null ? '' : String(value.content).substring(0, 4000); - const children: IBrandingElement[] = tag === 'img' + 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']) { @@ -293,6 +297,8 @@ function normalizeAttributes(tag: string, value: any, state: INormalizationState 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) { @@ -303,9 +309,9 @@ function normalizeAttributes(tag: string, value: any, state: INormalizationState state.warnings.push('Attribute "' + name + '" was rejected.'); continue; } - if (name === 'href' || name === 'src') { + if (name === 'href' || name === 'src' || name === 'action') { const safeUrl: string = sanitizeNavigationUrl(rawValue, state.siteCollectionUrl, name === 'href'); - if (safeUrl) { result[name] = safeUrl; } else { state.warnings.push('URL attribute was rejected.'); } + 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') { @@ -315,12 +321,24 @@ function normalizeAttributes(tag: string, value: any, state: INormalizationState } else if (name === 'aria-hidden') { if (rawValue === 'true' || rawValue === 'false') { result[name] = rawValue; } } else if (name === 'type' && tag === 'button') { - result.type = '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 = 'button'; } + 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; } @@ -330,6 +348,8 @@ function isAttributeAllowed(tag: string, name: string): boolean { 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; } diff --git a/src/extensions/customBranding/BrandingTypes.ts b/src/extensions/customBranding/BrandingTypes.ts index 0cd214b..038b75d 100644 --- a/src/extensions/customBranding/BrandingTypes.ts +++ b/src/extensions/customBranding/BrandingTypes.ts @@ -1,4 +1,4 @@ -export type BrandingElementType = 'div' | 'span' | 'p' | 'a' | 'button' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em' | 'nav' | 'section'; +export type BrandingElementType = 'div' | 'span' | 'p' | 'a' | 'button' | 'form' | 'input' | 'img' | 'h1' | 'h2' | 'h3' | 'strong' | 'em' | 'nav' | 'section'; export interface ICssFile { path: string; diff --git a/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts b/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts index 911b267..cce95c8 100644 --- a/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts +++ b/src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts @@ -39,7 +39,7 @@ export default class CustomBrandingApplicationCustomizer this.context.pageContext.site.absoluteUrl ); this._config = result.config; - this.debug(strings.Initialized + ' 3.0.4.', { + this.debug(strings.Initialized + ' 3.0.5.', { schemaVersion: this._config.schemaVersion, cssFileCount: this._config.cssfiles.length, warningCount: result.warnings.length diff --git a/tests/BrandingConfig.test.js b/tests/BrandingConfig.test.js index e75b4fa..5d32cac 100644 --- a/tests/BrandingConfig.test.js +++ b/tests/BrandingConfig.test.js @@ -49,9 +49,9 @@ assert(customBottom.config.placeholderbottom.elements.length === 1 && var legacy = normalize({ elements: [{ type: 'span', content: 'Alt' }] }, siteUrl); assert(legacy.config.placeholdertop.elements.length === 1, 'Das Legacy-Root-Array muss weiter funktionieren.'); -['div', 'span', 'p', 'a', 'button', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'] +['div', 'span', 'p', 'a', 'button', 'form', 'input', 'img', 'h1', 'h2', 'h3', 'strong', 'em', 'nav', 'section'] .forEach(function (tag) { - var attributes = tag === 'img' ? { alt: '' } : undefined; + var attributes = tag === 'img' ? { alt: '' } : (tag === 'form' ? { action: '/search' } : undefined); var content = tag === 'a' || tag === 'button' ? tag : undefined; assert(!!element({ type: tag, content: content, attributes: attributes }), 'Erlaubter Tag fehlt: ' + tag); }); @@ -83,6 +83,24 @@ assert(!element({ type: 'img', attributes: { src: '/logo.png' } }), 'Bilder ohne assert(!!element({ type: 'img', attributes: { src: '/logo.png', alt: '' } }), 'Dekorative Bilder mit leerem alt sind erlaubt.'); assert(!element({ type: 'button' }), 'Leere Buttons muessen verworfen werden.'); +var searchForm = element({ + type: 'form', + attributes: { action: '~sitecollection/_layouts/15/search.aspx/siteall', method: 'post' }, + children: [ + { type: 'input', attributes: { type: 'text', name: 'q', placeholder: 'Suchen', autocomplete: 'off' } }, + { type: 'button', attributes: { type: 'submit', 'aria-label': 'Suchen' } } + ] +}); +assert(searchForm.attributes.action === siteUrl + '/_layouts/15/search.aspx/siteall' && searchForm.attributes.method === 'get', + 'Suchformulare muessen eine sichere GET-Action verwenden.'); +assert(searchForm.children[0].attributes.type === 'search' && searchForm.children[0].attributes.name === 'q', + 'Suchfelder muessen auf type=search begrenzt sein.'); +assert(searchForm.children[1].attributes.type === 'submit', 'Submit-Buttons in Suchformularen muessen erhalten bleiben.'); +assert(!element({ type: 'form', attributes: { action: 'javascript:alert(1)' } }), + 'Formulare mit unsicherer Action muessen verworfen werden.'); +assert(!element({ type: 'form', attributes: { action: '//evil.example.org/search' } }), + 'Formulare mit protokollrelativer Action muessen verworfen werden.'); + var css = normalize({ allowedCssHosts: ['cdn.example.org'], cssfiles: [ diff --git a/tests/validate-static-assets.js b/tests/validate-static-assets.js index 7a7c043..b9f4da5 100644 --- a/tests/validate-static-assets.js +++ b/tests/validate-static-assets.js @@ -21,18 +21,21 @@ var packageJson = json('package.json'); var solution = json('config/package-solution.json').solution; var serveText = read('config/serve.json').toLowerCase(); var appSource = read('src/extensions/customBranding/CustomBrandingApplicationCustomizer.ts'); +var configSource = read('src/extensions/customBranding/BrandingConfig.ts'); var rendererSource = read('src/extensions/customBranding/BrandingDomRenderer.ts'); var classicSource = read('classic/custom-branding-classic.js'); var deploymentSource = read('deployment/add-custombranding.ps1'); -assert(packageJson.version === '3.0.4', 'package.json hat nicht Version 3.0.4.'); -assert(solution.version === '3.0.4.0', 'Solution-Version ist inkonsistent.'); +assert(packageJson.version === '3.0.5', 'package.json hat nicht Version 3.0.5.'); +assert(solution.version === '3.0.5.0', 'Solution-Version ist inkonsistent.'); assert(solution.skipFeatureDeployment === true, 'Tenantweite Bereitstellung ist nicht aktiviert.'); assert(!solution.features, 'Die alte web-scoped Feature-Registrierung ist noch vorhanden.'); assert(!fs.existsSync(path.join(root, 'sharepoint/assets/elements.xml')), 'elements.xml muss entfernt sein.'); assert(serveText.indexOf('onclick') < 0, 'serve.json enthaelt ein Event-Attribut.'); assert(serveText.indexOf('placeholdertop') >= 0, 'serve.json verwendet nicht das echte Schema.'); assert(rendererSource.indexOf('innerHTML') < 0 && appSource.indexOf('innerHTML') < 0, 'Der moderne Renderer darf innerHTML nicht verwenden.'); +assert(configSource.indexOf("'form', 'input'") >= 0 && configSource.indexOf("result.method = 'get'") >= 0, + 'Sichere deklarative Suchformulare sind nicht freigeschaltet.'); assert(classicSource.indexOf('innerHTML') < 0, 'Die Classic-Runtime darf innerHTML nicht verwenden.'); assert(classicSource.indexOf('~sitecollection/SitePages/PortalSettings.aspx') >= 0, 'Der Standard-Footer fehlt in der Classic-Runtime.');