From a863283680d2581405fe4e37136c1bdd5e792a4d Mon Sep 17 00:00:00 2001 From: Jordi Mallach Date: Fri, 16 Oct 2026 16:12:28 +0200 Subject: [PATCH] feat(web): add optional Content Security Policy AngularJS (EOL since 2022-01) and AngularJS Material no longer receive security fixes, so any new vulnerability in them will stay unpatched. In AngularJS, templates are code: any attacker-controlled text that ends up in a compiled template is evaluated as an expression, and since 1.6 there is no expression sandbox, so a single {{constructor.constructor('...')()}} gives full script execution. SOGo renders untrusted data (mail bodies, invitations, attachment names, contact fields) into such templates and currently relies on server-side filtering, like stripping "{{" and "}}", to prevent this. Any gap in that filtering, or a bypass of $sanitize/ng-bind-html, would mean XSS in the webmail session: reading and sending mail as the user, and access to calendars and contacts. A Content Security Policy adds a second layer in the browser that doesn't depend on catching every injection. The new SOGoContentSecurityPolicy system default ("report-only" or "enforce", disabled when unset) sends a strict policy with the pages of the web interface: script-src 'self' 'nonce-' (no 'unsafe-inline'/'unsafe-eval') style-src 'self' 'unsafe-inline' (Angular Material, CKEditor 5) object-src 'none'; base-uri 'self'; frame-ancestors 'self'; ... The policy is generated by sogod, since a fresh 128-bit nonce is needed per response: it is shared by all components of the response and set on the inline configuration scripts of the page frame and module views. The page also gets ng-csp="no-unsafe-eval", so AngularJS evaluates expressions with its interpreter instead of Function(). With the policy enforced, an expression injected into a compiled template, such as {{constructor.constructor('...')()}}, can no longer reach Function() and fails with an EvalError, and injected inline scripts are blocked. Injected expressions are limited to calling functions available on the scope. Tested with Chromium in both modes on login, Mail (HTML messages, remote content, invitations, compose), Calendar and Contacts editors, Preferences and Administration: no violations from SOGo's own code. With the default unset, the generated output is unchanged. form-action is deliberately not restricted, so SAML2/CAS/OpenID logins posting to an external IdP keep working. Scripts listed in SOGoUIAdditionalJSFiles are served from WebServerResources and allowed, but must not load scripts from other origins, use eval() or rely on inline handlers; their dependencies have to be installed and listed alongside them. --- Documentation/SOGoInstallationGuide.asciidoc | 20 ++++++++ SoObjects/SOGo/SOGoSystemDefaults.h | 1 + SoObjects/SOGo/SOGoSystemDefaults.m | 12 +++++ UI/Common/UIxPageFrame.m | 48 +++++++++++++++++++ UI/SOGoUI/UIxComponent.h | 3 ++ UI/SOGoUI/UIxComponent.m | 28 +++++++++++ .../ContactsUI/UIxContactFoldersView.wox | 2 +- UI/Templates/MailerUI/UIxMailMainFrame.wox | 2 +- UI/Templates/MainUI/SOGoRootPage.wox | 2 +- UI/Templates/PreferencesUI/UIxPreferences.wox | 2 +- UI/Templates/SchedulerUI/UIxCalMainView.wox | 2 +- .../SchedulerUI/UIxCalendarSelector.wox | 2 +- UI/Templates/UIxPageFrame.wox | 4 +- 13 files changed, 120 insertions(+), 8 deletions(-) diff --git a/Documentation/SOGoInstallationGuide.asciidoc b/Documentation/SOGoInstallationGuide.asciidoc index 9e582aab8..07fcb475d 100644 --- a/Documentation/SOGoInstallationGuide.asciidoc +++ b/Documentation/SOGoInstallationGuide.asciidoc @@ -2411,6 +2411,26 @@ Defaults to `SOGo` when unset. an additional icon will appear near the logout button in SOGo's web interface. The URL will always be open in a separate page. +|S |SOGoContentSecurityPolicy +|Parameter used to send a Content Security Policy with the pages of +the Web interface. The policy only allows the scripts served by SOGo +and the inline scripts it generates itself, and forbids `eval()`, which +protects against script injection. Possible values are: + +[options="compact"] +* `report-only` - send the policy as `Content-Security-Policy-Report-Only`: + violations are only reported in the browser console +* `enforce` - send the policy as `Content-Security-Policy` + +The files listed in `SOGoUIAdditionalJSFiles` are served by SOGo and +allowed by the policy. However, they will fail if they load further +scripts from another origin, evaluate strings with `eval()` or +`new Function()`, or add inline scripts or event handlers (`onclick=`). +Install any script they depend on under `WebServerResources` and list it +in `SOGoUIAdditionalJSFiles` as well. + +The policy is disabled when unset. + |U |SOGoLoginModule |Parameter used to specify which module to show after login. Possible values are: diff --git a/SoObjects/SOGo/SOGoSystemDefaults.h b/SoObjects/SOGo/SOGoSystemDefaults.h index 713a1773b..7e5de83a2 100644 --- a/SoObjects/SOGo/SOGoSystemDefaults.h +++ b/SoObjects/SOGo/SOGoSystemDefaults.h @@ -81,6 +81,7 @@ static const NSString *kDisableSharingCalendar = @"Calendar"; - (BOOL) uixAdditionalPreferences; - (BOOL) uixDebugEnabled; +- (NSString *) contentSecurityPolicy; - (BOOL) easDebugEnabled; - (BOOL) openIdDebugEnabled; - (BOOL) apiDebugEnabled; diff --git a/SoObjects/SOGo/SOGoSystemDefaults.m b/SoObjects/SOGo/SOGoSystemDefaults.m index 6afbedd7e..2d4d825b1 100644 --- a/SoObjects/SOGo/SOGoSystemDefaults.m +++ b/SoObjects/SOGo/SOGoSystemDefaults.m @@ -644,6 +644,18 @@ _injectConfigurationFromFile (NSMutableDictionary *defaultsDict, return [self boolForKey: @"SOGoUIxDebugEnabled"]; } +/* Returns "report-only", "enforce" or nil when disabled */ +- (NSString *) contentSecurityPolicy +{ + NSString *mode; + + mode = [[self stringForKey: @"SOGoContentSecurityPolicy"] lowercaseString]; + if ([mode isEqualToString: @"report-only"] || [mode isEqualToString: @"enforce"]) + return mode; + + return nil; +} + - (BOOL) easDebugEnabled { return [self boolForKey: @"SOGoEASDebugEnabled"]; diff --git a/UI/Common/UIxPageFrame.m b/UI/Common/UIxPageFrame.m index a5d4a8386..22abcafae 100644 --- a/UI/Common/UIxPageFrame.m +++ b/UI/Common/UIxPageFrame.m @@ -25,6 +25,7 @@ #import /* for locale strings */ #import +#import #import #import @@ -675,6 +676,53 @@ [[ud animationMode] isEqualToString: @"none"]; } +/* Content Security Policy */ + +// +// AngularJS must evaluate expressions without eval() when the policy +// doesn't allow 'unsafe-eval'. +// +- (NSString *) ngCSP +{ + if ([[SOGoSystemDefaults sharedSystemDefaults] contentSecurityPolicy]) + return @"no-unsafe-eval"; + + return nil; +} + +- (NSString *) contentSecurityPolicyHeader +{ + return [NSString stringWithFormat: + @"default-src 'self'; " + @"script-src 'self' 'nonce-%@'; " + @"style-src 'self' 'unsafe-inline'; " + @"img-src * data: blob:; " + @"font-src 'self' data:; " + @"media-src 'self' data: blob:; " + @"frame-src 'self' data: blob:; " + @"worker-src 'self' blob:; " + @"connect-src 'self'; " + @"object-src 'none'; " + @"base-uri 'self'; " + @"frame-ancestors 'self'", + [self cspNonce]]; +} + +- (void) appendToResponse: (WOResponse *) aResponse + inContext: (WOContext *) aContext +{ + NSString *mode; + + [super appendToResponse: aResponse inContext: aContext]; + + mode = [[SOGoSystemDefaults sharedSystemDefaults] contentSecurityPolicy]; + if (mode && ![self hideFrame] && [self cspNonce]) + [aResponse setHeader: [self contentSecurityPolicyHeader] + forKey: ([mode isEqualToString: @"enforce"] + ? @"content-security-policy" + : @"content-security-policy-report-only")]; +} + - (BOOL) isCompatibleBrowser { WEClientCapabilities *cc; diff --git a/UI/SOGoUI/UIxComponent.h b/UI/SOGoUI/UIxComponent.h index dd909f576..b91de933f 100644 --- a/UI/SOGoUI/UIxComponent.h +++ b/UI/SOGoUI/UIxComponent.h @@ -124,6 +124,9 @@ - (NSString *) buildDate; - (BOOL) isUIxDebugEnabled; +/* Content Security Policy */ +- (NSString *) cspNonce; + @end #endif /* __UIxComponent_H_ */ diff --git a/UI/SOGoUI/UIxComponent.m b/UI/SOGoUI/UIxComponent.m index 5cd0c27fc..bf73a4134 100644 --- a/UI/SOGoUI/UIxComponent.m +++ b/UI/SOGoUI/UIxComponent.m @@ -29,8 +29,10 @@ #import #import #import +#import #import +#import #import #import #import @@ -776,6 +778,32 @@ static SoProduct *commonProduct = nil; return [sd uixDebugEnabled]; } +/* Content Security Policy */ + +// +// Nonce allowing the inline scripts of the current response to run. It +// is shared by all the components of the response through the context +// and is nil when no policy is configured, so no attribute is rendered. +// +- (NSString *) cspNonce +{ + NSString *nonce; + + if (![[SOGoSystemDefaults sharedSystemDefaults] contentSecurityPolicy]) + return nil; + + nonce = [context objectForKey: @"SOGoCSPNonce"]; + if (!nonce) + { + nonce = [[NSData generateSaltForLength: 16 withPrintable: NO] + stringByEncodingBase64]; + if (nonce) + [context setObject: nonce forKey: @"SOGoCSPNonce"]; + } + + return nonce; +} + // // Protection against XSRF // diff --git a/UI/Templates/ContactsUI/UIxContactFoldersView.wox b/UI/Templates/ContactsUI/UIxContactFoldersView.wox index 11119e5bb..f1643ee66 100644 --- a/UI/Templates/ContactsUI/UIxContactFoldersView.wox +++ b/UI/Templates/ContactsUI/UIxContactFoldersView.wox @@ -9,7 +9,7 @@ className="UIxPageFrame" title="moduleName" const:jsFiles="vendor/ckeditor/build/ckeditor.js, Common/sgCkeditor.component.js, Common.js, Preferences.services.js, Mailer.services.js, Contacts.js, Contacts.services.js, vendor/angular-file-upload.min.js, vendor/FileSaver.min.js, vendor/punycode.js"> - diff --git a/UI/Templates/MailerUI/UIxMailMainFrame.wox b/UI/Templates/MailerUI/UIxMailMainFrame.wox index 2a20e5eae..c0b0f0687 100644 --- a/UI/Templates/MailerUI/UIxMailMainFrame.wox +++ b/UI/Templates/MailerUI/UIxMailMainFrame.wox @@ -9,7 +9,7 @@ className="UIxPageFrame" title="moduleName" const:jsFiles="vendor/ckeditor/build/ckeditor.js, Common/sgCkeditor.component.js, Common.js, Preferences.services.js, Contacts.services.js, Scheduler.services.js, Mailer.js, Mailer.services.js, vendor/angular-file-upload.min.js, vendor/FileSaver.min.js, vendor/punycode.js, vendor/mark.min.js"> - diff --git a/UI/Templates/UIxPageFrame.wox b/UI/Templates/UIxPageFrame.wox index b5bab3285..afee52b18 100644 --- a/UI/Templates/UIxPageFrame.wox +++ b/UI/Templates/UIxPageFrame.wox @@ -11,7 +11,7 @@ - + <var:string value="title"/> @@ -62,7 +62,7 @@ -