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-<random>'   (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.
This commit is contained in:
Jordi Mallach authored and QHivert committed 2026-11-06 16:22:03 +01:00
1 parent ee02866730
commit a863283680
13 files changed
+120 -8

No files matched your search

@@ -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:
+1
View File
@@ -81,6 +81,7 @@ static const NSString *kDisableSharingCalendar = @"Calendar";
- (BOOL) uixAdditionalPreferences;
- (BOOL) uixDebugEnabled;
- (NSString *) contentSecurityPolicy;
- (BOOL) easDebugEnabled;
- (BOOL) openIdDebugEnabled;
- (BOOL) apiDebugEnabled;
+12
View File
@@ -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"];
+48
View File
@@ -25,6 +25,7 @@
#import <Foundation/NSUserDefaults.h> /* for locale strings */
#import <NGObjWeb/WOResourceManager.h>
#import <NGObjWeb/WOResponse.h>
#import <SOGo/NSArray+Utilities.h>
#import <SOGo/NSDictionary+Utilities.h>
@@ -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;
+3
View File
@@ -124,6 +124,9 @@
- (NSString *) buildDate;
- (BOOL) isUIxDebugEnabled;
/* Content Security Policy */
- (NSString *) cspNonce;
@end
#endif /* __UIxComponent_H_ */
+28
View File
@@ -29,8 +29,10 @@
#import <NGExtensions/NSObject+Logs.h>
#import <NGExtensions/NSString+misc.h>
#import <NGExtensions/NSURL+misc.h>
#import <NGExtensions/NGBase64Coding.h>
#import <SOGo/NSCalendarDate+SOGo.h>
#import <SOGo/NSData+Crypto.h>
#import <SOGo/NSDictionary+Utilities.h>
#import <SOGo/NSObject+Utilities.h>
#import <SOGo/NSString+Crypto.h>
@@ -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
//
@@ -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">
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var contactFolders = <var:string value="contactFolders.jsonRepresentation" const:escapeHTML="NO" />;
</script>
+1 -1
View File
@@ -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">
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var mailAccounts = <var:string value="mailAccounts" const:escapeHTML="NO" />;
var userNames = <var:string value="userNames" const:escapeHTML="NO" />;
var unseenCountFolders = <var:string value="unseenCountFolders" const:escapeHTML="NO" />;
+1 -1
View File
@@ -9,7 +9,7 @@
xmlns:label="OGo:label"
const:jsFiles="Main.js, Common.js"
>
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var cookieUsername = <var:string var:value="cookieUsername.doubleQuotedString" const:escapeHTML="NO"/>;
var language = '<var:string var:value="language" const:escapeHTML="NO"/>';
var loginHint = '<var:string var:value="getLoginHint" const:escapeHTML="NO"/>'
@@ -13,7 +13,7 @@
<main layout-fill="layout-fill" ui-view="preferences"
ng-controller="navController"><!-- preferences --> </main>
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var mailCustomFromEnabled = <var:string value="mailCustomFromEnabled" const:escapeHTML="NO"/>;
var forwardEnabled = <var:string value="forwardEnabled" const:escapeHTML="NO"/>;
var notificationEnabled = <var:string value="notificationEnabled" const:escapeHTML="NO"/>;
+1 -1
View File
@@ -9,7 +9,7 @@
className="UIxPageFrame"
title="moduleName"
const:jsFiles="vendor/ckeditor/build/ckeditor.js, vendor/ng-sortable.min.js, Common/sgCkeditor.component.js, Common.js, Preferences.services.js, Contacts.services.js, Mailer.services.js, vendor/angular-file-upload.min.js, Scheduler.js, Scheduler.services.js, vendor/FileSaver.min.js, vendor/punycode.js">
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var firstDayOfWeek = <var:string value="firstDayOfWeek"/>;
var dayStartHour = <var:string value="dayStartHour"/>;
var currentView = '<var:string value="currentView"/>';
@@ -6,7 +6,7 @@
xmlns:const="http://www.skyrix.com/od/constant"
xmlns:rsrc="OGo:url"
xmlns:label="OGo:label">
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var calendarsData = <var:string value="calendars.jsonRepresentation" const:escapeHTML="NO" />;
</script>
</container>
+2 -2
View File
@@ -11,7 +11,7 @@
<var:string var:value="doctype" const:escapeHTML="NO" />
<var:if condition="hideFrame" const:negate="YES">
<html const:xmlns="http://www.w3.org/1999/xhtml" var:lang="localeCode">
<html const:xmlns="http://www.w3.org/1999/xhtml" var:lang="localeCode" var:ng-csp="ngCSP">
<head>
<title var:sg-default="title"><var:string value="title"/></title>
<meta name="hideFrame" var:content="hideFrame" />
@@ -62,7 +62,7 @@
<!-- MAIN CONTENT ROW -->
<var:component-content />
<script type="text/javascript">
<script type="text/javascript" var:nonce="cspNonce">
var ApplicationBaseURL = '<var:string value="modulePath" />';
var ResourcesURL = '<var:string value="applicationPath" />.woa/WebServerResources';
var minimumSearchLength = <var:string value="minimumSearchLength" />;