@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.
- package/dist/applySecurityMiddleware.d.ts +10 -0
- package/dist/applySecurityMiddleware.js +116 -0
- package/dist/applySecurityMiddleware.test.d.ts +1 -0
- package/dist/applySecurityMiddleware.test.js +99 -0
- package/dist/client/assets/{index-DfW2mfd6.js → index-DHHRN8Yr.js} +39 -39
- package/dist/client/assets/{index-DfW2mfd6.js.map → index-DHHRN8Yr.js.map} +1 -1
- package/dist/client/index.html +1 -1
- package/dist/index.js +24 -97
- package/package.json +9 -7
|
@@ -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
|
+
}
|