CSP and Trusted Types
OGE UI runs under a strict Content Security Policy: no inline scripts, no eval, no string-built DOM, and two named Trusted Types policies for the only two parsers in the suite. The policy below is the one the end-to-end suite serves this site under.
What the packages need
| Directive | OGE UI needs | Why |
|---|---|---|
script-src | 'self' (or a nonce with 'strict-dynamic' ) | No inline scripts, no eval , no new Function , no document.write . Export libraries load as ordinary lazy chunks. |
style-src | 'self' + a nonce (Angular) or 'unsafe-inline' | Angular adds component <style> elements; React ships a stylesheet file. |
style-src-attr | 'unsafe-inline' | Layout values in server-rendered style attributes (virtual-scroll offsets, panel positions). |
img-src | 'self' data: blob: | Upload previews and image exports use object URLs; the editor allows data:image/* only when enabled. |
font-src | 'self' | PDF exports embed only a font you serve ( setOgePdfDefaultFont() ); nothing is fetched from a CDN. |
connect-src | your API only | Components fetch nothing on their own; remote data goes through your DataSource . |
require-trusted-types-for | 'script' + the policy names below | Two parsers, each behind a named policy. |
A static host cannot mint a per-request nonce. There, allow the component styles inline and keep scripts strict:
# a static host cannot mint a nonce — allow component styles inline
Content-Security-Policy:
default-src 'self';
script-src 'self';
style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:;
object-src 'none';
base-uri 'self'A tested strict policy
apps/dev-app-e2e/ssr/strict-csp.spec.ts serves the prerendered site with this header (a fresh nonce per request) and fails on any securitypolicyviolation while it hydrates the pages, exports a grid to CSV and Excel (the lazy export chunk loads under 'strict-dynamic'), imports BPMN XML, renders an HTML overlay badge, opens a modal in both render layers and exports a chart to JPEG and PDF.
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{NONCE}' 'strict-dynamic';
style-src 'self' 'nonce-{NONCE}';
style-src-attr 'unsafe-inline';
img-src 'self' data: blob:;
font-src 'self' data:;
connect-src 'self';
worker-src 'self' blob:;
object-src 'none';
base-uri 'self';
frame-ancestors 'none';
form-action 'self';
require-trusted-types-for 'script';
trusted-types angular angular#bundler oge-ui#bpmn oge-ui#editorNonces
Angular adds a <style> element per component stylesheet, OGE's included. Give Angular the request's nonce and it stamps it on each of them — the strict run injects it as ngCspNonce on the root element, the way a nonce-injecting SSR server or edge function would:
<!-- index.html as your server sends it: the same per-request nonce as the header.
Angular stamps it on the <style> elements it adds for component styles. -->
<app-root ngCspNonce="{NONCE}"></app-root>// …or provide it in code (e.g. read from a meta tag your server writes)
import { ApplicationConfig, CSP_NONCE } from '@angular/core';
export const appConfig: ApplicationConfig = {
providers: [
{
provide: CSP_NONCE,
useFactory: () =>
document.querySelector<HTMLMetaElement>('meta[name="csp-nonce"]')
?.content ?? null,
},
],
}; Server-rendered layout values (virtual-scroll offsets, popup positions) arrive as inline style attributes, hence style-src-attr 'unsafe-inline'; they cannot run script.
Trusted Types
Under require-trusted-types-for 'script' the suite has exactly two sinks, both DOMParser.parseFromString into an inert document that is only walked, never inserted. Each engine creates its policy once, lazily, the first time it parses.
| Policy | Created by | When |
|---|---|---|
oge-ui#editor | @oge-ui/behavior (used by @oge-ui/editor , @oge-ui/react-editor ) | Parsing a value or a clipboard payload in the rich-text editor. |
oge-ui#bpmn | @oge-ui/bpmn-engine | Importing BPMN XML, and overlay badge markup in the React layer. |
angular | Angular | Its sanitizing [innerHTML] (BPMN overlay badges in the Angular layer). Angular apps only. |
angular#bundler | Angular | Loading lazy chunks. Angular apps only. |
The suite never calls a bypassSecurityTrust* API, so angular#unsafe-bypass is not needed. A new sink must reuse a documented policy or add one to SECURITY.md — the strict run reads its list of names from that file, so an undocumented policy fails it.
URLs from data
Data-driven links (menu items, breadcrumbs, menubar items) can carry a javascript: URL. Angular's [href] binding neutralises it; React's href does not, so every React component routes data-driven href/src through sanitizeUrl / sanitizeResourceUrl from @oge-ui/behavior. Use the same functions for links you render yourself:
| Input | sanitizeUrl | sanitizeResourceUrl |
|---|---|---|
/orders/42 , https://… , mailto: , tel: , ftp: , sms: | kept | kept |
javascript: , vbscript: , unknown schemes | about:blank | about:blank |
blob:… , data:image/png;… | about:blank | kept |
data:text/html;… (markup) | about:blank | about:blank |
a custom scheme with allowedSchemes: ['web+app'] | kept | about:blank (no options) |
'use client';
import { sanitizeUrl } from '@oge-ui/behavior';
interface Link {
readonly label: string;
readonly url: string;
}
// links from a CMS or an API: javascript: and other script schemes become about:blank
export function CmsLinks({ links }: { links: readonly Link[] }) {
return (
<ul>
{links.map((link) => (
<li key={link.label}>
<a href={sanitizeUrl(link.url, { allowedSchemes: ['web+app'] })}>
{link.label}
</a>
</li>
))}
</ul>
);
}Rich text
The rich-text editor is the one component whose value is markup. The bound value, every paste and drop, and insertHtml() go through one allowlist parser into the editor's document model: allow-listed tags, href (sanitizeUrl), src (sanitizeResourceUrl; no blob:, file: or SVG; data:image/* only with allowDataImages), alt, title, dir, validated colours and text-align. Everything else — script, style, svg, iframe, event handlers — is dropped, and the emitted value is re-serialized from the model. ogeSanitizeEditorHtml() applies the same allowlist to HTML you render elsewhere, in the browser or on a server:
import { ChangeDetectionStrategy, Component } from '@angular/core';
import { ogeSanitizeEditorHtml } from '@oge-ui/editor';
@Component({
selector: 'demo-root',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<article [innerHTML]="html"></article>
`,
})
export class Demo {
// stored rich text, rendered outside the editor: the same allowlist the editor
// applies to its value, pastes and drops (scripts, styles, svg, iframes and
// event handlers are dropped; href/src go through sanitizeUrl)
readonly html = ogeSanitizeEditorHtml(
'<p>Hello <a href="javascript:alert(1)">there</a><script>steal()</script></p>',
);
}Client-side sanitizing is a convenience, not your server's defence: still sanitize stored HTML on the server, as you would any user-written markup. The same goes for upload rules — see SECURITY.md for exports (CSV formula guard), restored state and the reporting process.
What is not covered
- The strict run does not drive the rich-text editor yet; its
oge-ui#editorpolicy is documented and listed in the policy, but no end-to-end test types into the editor underrequire-trusted-types-for. - The scheduler's
print()adds a print stylesheet to its print frame as a<style>element without a nonce; under a nonce-onlystyle-srcthat sheet is blocked and the printout keeps the on-screen scroll heights. - The production docs site itself uses the static-host shape (script hashes,
'unsafe-inline'styles) — see the SSR guide for the one inline script event replay needs.