@botfabrik/engine-webclient 4.109.12-alpha.2 → 4.109.12-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ import type { Router } from 'express';
2
+ import type { WebClientProps } from './types.js';
3
+ /**
4
+ * Applies CORS, Helmet (CSP) and Permissions-Policy middleware to the given router.
5
+ *
6
+ * @param router - The Express router to apply middleware to.
7
+ * @param props - The webclient configuration.
8
+ * @param baseUrl - The server's own base URL (e.g. "http://localhost:3000").
9
+ */
10
+ export declare function applySecurityMiddleware(router: Router, props: WebClientProps, baseUrl: string): void;
@@ -0,0 +1,116 @@
1
+ import cors from 'cors';
2
+ import helmet from 'helmet';
3
+ /**
4
+ * Applies CORS, Helmet (CSP) and Permissions-Policy middleware to the given router.
5
+ *
6
+ * @param router - The Express router to apply middleware to.
7
+ * @param props - The webclient configuration.
8
+ * @param baseUrl - The server's own base URL (e.g. "http://localhost:3000").
9
+ */
10
+ export function applySecurityMiddleware(router, props, baseUrl) {
11
+ const { allowedOrigins, trustedResourceOrigins } = props;
12
+ // CORS
13
+ // When allowedOrigins is undefined, all origins are permitted (backward-compatible default).
14
+ // When it is set (even to an empty array), only the listed origins plus the server's own
15
+ // base URL are allowed.
16
+ router.use(cors({
17
+ origin: allowedOrigins === undefined
18
+ ? true
19
+ : (origin, callback) => {
20
+ // The server's own origin is always allowed.
21
+ const effectiveAllowed = [baseUrl, ...allowedOrigins];
22
+ if (!origin || effectiveAllowed.includes(origin)) {
23
+ callback(null, true);
24
+ }
25
+ else {
26
+ callback(new Error(`CORS: Origin '${origin}' is not allowed`));
27
+ }
28
+ },
29
+ methods: ['GET', 'POST', 'OPTIONS'],
30
+ allowedHeaders: ['Content-Type', 'Authorization'],
31
+ credentials: true,
32
+ }));
33
+ // Helmet (CSP + security headers)
34
+ router.use(helmet({
35
+ contentSecurityPolicy: {
36
+ directives: {
37
+ defaultSrc: ["'self'"],
38
+ scriptSrc: ["'self'"],
39
+ // stylesheets: allow all HTTPS sources when no trustedResourceOrigins are configured
40
+ // (backward-compatible default); otherwise restrict to the listed origins
41
+ styleSrc: trustedResourceOrigins === undefined
42
+ ? ["'self'", "'unsafe-inline'", 'https:']
43
+ : ["'self'", "'unsafe-inline'", ...trustedResourceOrigins],
44
+ // images: restrict to trusted resource origins when defined, otherwise allow all HTTPS
45
+ imgSrc: trustedResourceOrigins === undefined
46
+ ? ["'self'", 'data:', 'https:']
47
+ : ["'self'", 'data:', ...trustedResourceOrigins],
48
+ connectSrc: [
49
+ "'self'",
50
+ // allow websocket connections back to the same server
51
+ baseUrl.replace(/^http/, 'ws'),
52
+ baseUrl.replace(/^http/, 'wss'),
53
+ ],
54
+ // fonts may be loaded from trusted resource origins (e.g. Google Fonts);
55
+ // when no trustedResourceOrigins are configured, allow fonts from all origins
56
+ fontSrc: trustedResourceOrigins === undefined
57
+ ? ['*']
58
+ : ["'self'", ...trustedResourceOrigins],
59
+ // when no allowedOrigins are configured, allow framing from all origins
60
+ frameAncestors: allowedOrigins === undefined
61
+ ? ['*']
62
+ : ["'self'", ...allowedOrigins],
63
+ // frame-src controls what this page may embed in an iframe.
64
+ // The /embed page loads the chatbot in an iframe on the same server.
65
+ // We include both http and https of the server's base URL because a browser
66
+ // with a cached HSTS policy for localhost may load the iframe via https://
67
+ // even when the embed page was served via http://.
68
+ frameSrc: [
69
+ "'self'",
70
+ baseUrl,
71
+ baseUrl.replace(/^http:/, 'https:'),
72
+ ...(allowedOrigins ?? []),
73
+ ],
74
+ objectSrc: ["'none'"],
75
+ baseUri: ["'self'"],
76
+ // When SAML auth is configured, the browser POSTs a form to the external IdP.
77
+ formAction: props.auth?.saml.entryPoint
78
+ ? ["'self'", props.auth.saml.entryPoint]
79
+ : ["'self'"],
80
+ // upgrade-insecure-requests is intentionally omitted:
81
+ // the webclient is embedded via iframe on external pages which may themselves be HTTP,
82
+ // and the directive would also affect those sub-resource loads in some browsers.
83
+ // Helmet adds upgrade-insecure-requests by default; we must explicitly disable it
84
+ // here because it would also upgrade HTTP redirects (e.g. bot.svg → /cms/...) to HTTPS,
85
+ // causing ERR_SSL_PROTOCOL_ERROR in local development.
86
+ upgradeInsecureRequests: null,
87
+ },
88
+ },
89
+ // Disable X-Frame-Options: we control framing exclusively via CSP frame-ancestors above.
90
+ // X-Frame-Options: SAMEORIGIN (helmet default) would block cross-origin iframe embedding.
91
+ frameguard: false,
92
+ // Send origin only, without path – sufficient for analytics while avoiding leaking URLs
93
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
94
+ // Disable HSTS: HTTPS enforcement is already handled globally in engine-core.
95
+ // Enabling it here would cause the browser to enforce HTTPS for the entire origin,
96
+ // which breaks local development (http://localhost:3000).
97
+ hsts: false,
98
+ // resources served by the webclient must be loadable cross-origin (e.g. embed script)
99
+ crossOriginResourcePolicy: { policy: 'cross-origin' },
100
+ }));
101
+ // Permissions-Policy: restrict browser feature access to the minimum required.
102
+ // Microphone is only permitted when speech-to-text is configured.
103
+ router.use((_req, res, next) => {
104
+ const microphonePolicy = props.speech
105
+ ? 'microphone=(self)'
106
+ : 'microphone=()';
107
+ res.setHeader('Permissions-Policy', [
108
+ microphonePolicy,
109
+ 'camera=()',
110
+ 'geolocation=()',
111
+ 'payment=()',
112
+ 'usb=()',
113
+ ].join(', '));
114
+ next();
115
+ });
116
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,99 @@
1
+ import express from 'express';
2
+ import supertest from 'supertest';
3
+ import { describe, expect, it } from 'vitest';
4
+ import { applySecurityMiddleware } from './applySecurityMiddleware.js';
5
+ const BASE_URL = 'http://localhost:3000';
6
+ describe('security middleware', () => {
7
+ describe('CORS', () => {
8
+ it('allows all origins when allowedOrigins is undefined', async () => {
9
+ const res = await supertest(makeApp({}))
10
+ .get('/')
11
+ .set('Origin', 'http://any-origin.example.com');
12
+ expect(res.headers['access-control-allow-origin']).toBe('http://any-origin.example.com');
13
+ });
14
+ it('allows the server baseUrl when allowedOrigins is []', async () => {
15
+ const res = await supertest(makeApp({ allowedOrigins: [] }))
16
+ .get('/')
17
+ .set('Origin', BASE_URL);
18
+ expect(res.headers['access-control-allow-origin']).toBe(BASE_URL);
19
+ });
20
+ it('allows an explicitly listed origin', async () => {
21
+ const res = await supertest(makeApp({ allowedOrigins: ['http://example.com'] }))
22
+ .get('/')
23
+ .set('Origin', 'http://example.com');
24
+ expect(res.headers['access-control-allow-origin']).toBe('http://example.com');
25
+ });
26
+ it('rejects an unlisted origin', async () => {
27
+ const res = await supertest(makeApp({ allowedOrigins: ['http://example.com'] }))
28
+ .get('/')
29
+ .set('Origin', 'http://evil.example.com');
30
+ // cors package sets the header to false/omits it when rejected
31
+ expect(res.headers['access-control-allow-origin']).toBeUndefined();
32
+ });
33
+ it('allows no-origin requests (same-origin non-browser) regardless of allowedOrigins', async () => {
34
+ const res = await supertest(makeApp({ allowedOrigins: [] })).get('/');
35
+ expect(res.status).toBe(200);
36
+ });
37
+ });
38
+ describe('CSP frame-ancestors', () => {
39
+ it("is '*' when allowedOrigins is undefined", async () => {
40
+ const res = await supertest(makeApp({})).get('/');
41
+ expect(res.headers['content-security-policy']).toContain('frame-ancestors *');
42
+ });
43
+ it('is "\'self\'" + listed origin when allowedOrigins is set', async () => {
44
+ const res = await supertest(makeApp({ allowedOrigins: ['http://example.com'] })).get('/');
45
+ expect(res.headers['content-security-policy']).toContain("frame-ancestors 'self' http://example.com");
46
+ });
47
+ it('is "\'self\'" only when allowedOrigins is []', async () => {
48
+ const res = await supertest(makeApp({ allowedOrigins: [] })).get('/');
49
+ expect(res.headers['content-security-policy']).toContain("frame-ancestors 'self'");
50
+ });
51
+ });
52
+ describe('CSP connectSrc', () => {
53
+ it('includes ws:// and wss:// variants of baseUrl', async () => {
54
+ const res = await supertest(makeApp({}, 'http://bot.example.com')).get('/');
55
+ const csp = res.headers['content-security-policy'];
56
+ expect(csp).toContain('ws://bot.example.com');
57
+ expect(csp).toContain('wss://bot.example.com');
58
+ });
59
+ });
60
+ describe('CSP trustedResourceOrigins', () => {
61
+ it('allows all HTTPS for styleSrc/imgSrc/fontSrc when undefined', async () => {
62
+ const res = await supertest(makeApp({})).get('/');
63
+ const csp = res.headers['content-security-policy'];
64
+ expect(csp).toContain('style-src');
65
+ expect(csp).toContain('https:');
66
+ expect(csp).toContain('font-src *');
67
+ });
68
+ it('restricts styleSrc/imgSrc/fontSrc to listed origins when set', async () => {
69
+ const res = await supertest(makeApp({ trustedResourceOrigins: ['https://fonts.googleapis.com'] })).get('/');
70
+ const csp = res.headers['content-security-policy'];
71
+ expect(csp).toContain('https://fonts.googleapis.com');
72
+ expect(csp).not.toContain('font-src *');
73
+ });
74
+ });
75
+ describe('Permissions-Policy', () => {
76
+ it('disables microphone when no speech config', async () => {
77
+ const res = await supertest(makeApp({})).get('/');
78
+ expect(res.headers['permissions-policy']).toContain('microphone=()');
79
+ });
80
+ it('allows microphone when speech is configured', async () => {
81
+ const res = await supertest(makeApp({ speech: { provider: 'google' } })).get('/');
82
+ expect(res.headers['permissions-policy']).toContain('microphone=(self)');
83
+ });
84
+ it('always disables camera, geolocation, payment and usb', async () => {
85
+ const res = await supertest(makeApp({})).get('/');
86
+ const pp = res.headers['permissions-policy'];
87
+ expect(pp).toContain('camera=()');
88
+ expect(pp).toContain('geolocation=()');
89
+ expect(pp).toContain('payment=()');
90
+ expect(pp).toContain('usb=()');
91
+ });
92
+ });
93
+ });
94
+ function makeApp(props, baseUrl = BASE_URL) {
95
+ const app = express();
96
+ applySecurityMiddleware(app, props, baseUrl);
97
+ app.get('/', (_req, res) => res.json({ ok: true }));
98
+ return app;
99
+ }