Files
sogo/UI/Common/UIxPageFrame.m
T
Jordi Mallach a863283680 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.
2026-11-06 16:22:03 +01:00

755 lines
18 KiB
Objective-C

/*
Copyright (C) 2004-2005 SKYRIX Software AG
Copyright (C) 2005-2022 Inverse inc.
This file is part of SOGo.
SOGo is free software; you can redistribute it and/or modify it under
the terms of the GNU Lesser General Public License as published by the
Free Software Foundation; either version 2, or (at your option) any
later version.
SOGo is distributed in the hope that it will be useful, but WITHOUT ANY
WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public
License for more details.
You should have received a copy of the GNU Lesser General Public
License along with OGo; see the file COPYING. If not, write to the
Free Software Foundation, 59 Temple Place - Suite 330, Boston, MA
02111-1307, USA.
*/
#import <Foundation/NSNull.h>
#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>
#import <SOGo/NSString+Utilities.h>
#import <SOGo/SOGoUser.h>
#import <SOGo/SOGoUserSettings.h>
#import <SOGo/SOGoSystemDefaults.h>
#import <SOGo/SOGoWebAuthenticator.h>
#import "UIxPageFrame.h"
@implementation UIxPageFrame
- (id) init
{
if ((self = [super init]))
{
NSString *filename;
SOGoUserDefaults *ud;
item = nil;
title = nil;
toolbar = nil;
udKeys = nil;
usKeys = nil;
additionalJSFiles = nil;
additionalCSSFiles = [NSMutableArray new];
systemAdditionalJSFiles = nil;
ud = [[context activeUser] userDefaults];
if ([[ud animationMode] isEqualToString: @"none"])
{
filename = [self urlForResourceFilename: @"css/no-animation.css"];
[additionalCSSFiles addObject: filename];
}
}
return self;
}
- (void) dealloc
{
[item release];
[title release];
[toolbar release];
[udKeys release];
[usKeys release];
[additionalJSFiles release];
[additionalCSSFiles release];
[systemAdditionalJSFiles release];
[super dealloc];
}
/* accessors */
- (void) setTitle: (NSString *) _value
{
ASSIGN (title, _value);
}
- (NSString *) title
{
NSString *pageTitle;
SOGoSystemDefaults *sd;
sd = [SOGoSystemDefaults sharedSystemDefaults];
pageTitle = [sd pageTitle];
if (pageTitle == nil || ![pageTitle length])
pageTitle = (@"SOGo");
if ([title length])
pageTitle = [NSString stringWithFormat: @"%@ | %@", title, pageTitle];
return pageTitle;
}
- (void) setItem: (id) _item
{
ASSIGN (item, _item);
}
- (id) item
{
return item;
}
- (NSString *) ownerInContext
{
return [[self clientObject] ownerInContext: nil];
}
- (NSString *) doctype
{
return (@"<!DOCTYPE html>");
}
/* Help URL */
- (NSString *) helpURL
{
SOGoSystemDefaults *sd;
NSString *s;
sd = [SOGoSystemDefaults sharedSystemDefaults];
if ((s = [sd helpURL]))
return s;
return @"";
}
/* notifications */
- (void) sleep
{
[item release];
item = nil;
[super sleep];
}
/* URL generation */
// TODO: I think all this should be done by the clientObject?!
- (NSString *) relativeHomePath
{
return [self relativePathToUserFolderSubPath: @""];
}
- (NSString *) relativeCalendarPath
{
return [self relativePathToUserFolderSubPath: @"Calendar/"];
}
- (NSString *) relativeContactsPath
{
return [self relativePathToUserFolderSubPath: @"Contacts/"];
}
- (NSString *) relativeMailPath
{
return [self relativePathToUserFolderSubPath: @"Mail/"];
}
- (NSString *) relativePreferencesPath
{
return [self relativePathToUserFolderSubPath: @"Preferences/"];
}
- (NSString *) relativeAdministrationPath
{
return [self relativePathToUserFolderSubPath: @"Administration/"];
}
- (NSString *) logoffPath
{
return [self relativePathToUserFolderSubPath: @"logoff"];
}
/* popup handling */
- (void) setPopup: (BOOL) popup
{
isPopup = popup;
}
- (BOOL) isPopup
{
return isPopup;
}
- (NSString *) bodyClasses
{
return (isPopup ? @"popup" : @"main");
}
- (NSString *) siteFavicon
{
NSString *siteFavicon;
siteFavicon = [[SOGoSystemDefaults sharedSystemDefaults]
faviconRelativeURL];
return (siteFavicon
? siteFavicon
: [self urlForResourceFilename: @"img/sogo.ico"]);
}
/* page based JavaScript */
- (NSDictionary *) _stringsForFramework: (NSString *) framework
{
NSDictionary *moreStrings;
NSString *language, *frameworkName;
NSMutableDictionary* strings;
id table;
// When no framework is specified, we load the strings from UI/Common
frameworkName = [NSString stringWithFormat: @"%@.SOGo",
(framework ? framework : [self frameworkName])];
language = [[context resourceLookupLanguages] objectAtIndex: 0];
table
= [[self resourceManager] stringTableWithName: @"Localizable"
inFramework: frameworkName
languages: [NSArray arrayWithObject: language]];
strings = [NSMutableDictionary dictionaryWithDictionary: table];
if (framework)
{
moreStrings = [NSDictionary dictionaryWithObjectsAndKeys: [NSArray arrayWithObject: framework], @"_loadedFrameworks", nil];
}
else
{
// Add strings from Locale
// AM/PM
moreStrings = [NSDictionary dictionaryWithObjects: [locale objectForKey: NSAMPMDesignation]
forKeys: [UIxComponent amPmLabelKeys]];
[strings addEntriesFromDictionary: moreStrings];
// Month names
moreStrings = [NSDictionary dictionaryWithObjects: [locale objectForKey: NSMonthNameArray]
forKeys: [UIxComponent monthLabelKeys]];
[strings addEntriesFromDictionary: moreStrings];
// Short month names
moreStrings = [NSDictionary dictionaryWithObjects: [locale objectForKey: NSShortMonthNameArray]
forKeys: [UIxComponent abbrMonthLabelKeys]];
}
[strings addEntriesFromDictionary: moreStrings];
/* table is not really an NSDictionary but a hackish variation thereof */
return strings;
}
- (NSString *) commonLocalizableStrings
{
return [NSString stringWithFormat: @"var clabels = %@;",
[[self _stringsForFramework: nil] jsonRepresentation]];
}
- (NSString *) productLocalizableStrings
{
NSString *frameworkName;
frameworkName = [[context page] frameworkName];
return [NSString stringWithFormat: @"var labels = %@;",
[[self _stringsForFramework: frameworkName] jsonRepresentation]];
}
- (WOResponse *) labelsAction
{
WOResponse *response;
NSDictionary *params, *data;
NSString *frameworkName;
params = [[[context request] contentAsString] objectFromJSONString];
frameworkName = [params objectForKey: @"framework"];
if (frameworkName)
{
data = [NSDictionary dictionaryWithObject: [self _stringsForFramework: frameworkName]
forKey: @"labels"];
response = [self responseWithStatus: 200 andJSONRepresentation: data];
}
else
{
data = [NSDictionary dictionaryWithObject: @"Missing framework name"
forKey: @"message"];
response = [self responseWithStatus: 400 andJSONRepresentation: data];
}
return response;
}
- (NSString *) angularModule
{
NSString *frameworkName;
frameworkName = [[context page] frameworkName];
return [NSString stringWithFormat: @"SOGo.%@", frameworkName];
}
- (NSString *) pageJavaScriptURL
{
WOComponent *page;
NSString *theme, *filename, *url;
url = nil;
page = [context page];
theme = [context objectForKey: @"theme"];
if ([theme length])
{
filename = [NSString stringWithFormat: @"js/%@/%@.js", theme, NSStringFromClass([page class])];
url = [self urlForResourceFilename: filename];
}
if ([url length] == 0)
{
// No theme defined or no specific JavaScript for the theme; rollback to default JavaScript
filename = [NSString stringWithFormat: @"js/%@.js", NSStringFromClass([page class])];
url = [self urlForResourceFilename: filename];
}
//NSLog(@"pageJavaScript => %@", filename);
return url;
}
- (NSString *) productJavaScriptURL
{
WOComponent *page;
NSString *theme, *filename, *url;
url = nil;
page = [context page];
[context resourceLookupLanguages];
theme = [context objectForKey: @"theme"];
if ([theme length])
{
filename = [NSString stringWithFormat: @"js/%@/%@.js", theme, [page frameworkName]];
url = [self urlForResourceFilename: filename];
}
if ([url length] == 0)
{
filename = [NSString stringWithFormat: @"js/%@.js", [page frameworkName]];
url = [self urlForResourceFilename: filename];
}
//NSLog(@"productJavaScript => %@", filename);
return url;
}
- (BOOL) hasPageSpecificJavaScript
{
return ([[self pageJavaScriptURL] length] > 0);
}
- (BOOL) hasProductSpecificJavaScript
{
return ([[self productJavaScriptURL] length] > 0);
}
- (void) setCssFiles: (NSString *) newCSSFiles
{
NSEnumerator *cssFiles;
NSString *currentFile, *filename;
cssFiles = [[newCSSFiles componentsSeparatedByString: @","] objectEnumerator];
while ((currentFile = [cssFiles nextObject]))
{
filename = [self urlForResourceFilename: [NSString stringWithFormat: @"css/%@", [currentFile stringByTrimmingSpaces]]];
[additionalCSSFiles addObject: filename];
}
}
- (NSArray *) additionalCSSFiles
{
return additionalCSSFiles;
}
- (void) setJsFiles: (NSString *) newJSFiles
{
NSEnumerator *jsFiles;
NSString *currentFile, *filename;
[additionalJSFiles release];
additionalJSFiles = [NSMutableArray new];
jsFiles = [[newJSFiles componentsSeparatedByString: @","] objectEnumerator];
while ((currentFile = [jsFiles nextObject]))
{
filename = [self urlForResourceFilename: [NSString stringWithFormat: @"js/%@", [currentFile stringByTrimmingSpaces]]];
[additionalJSFiles addObject: filename];
}
}
- (NSArray *) additionalJSFiles
{
return additionalJSFiles;
}
- (NSArray *) systemAdditionalJSFiles
{
NSArray *prefsJSFiles;
SOGoDomainDefaults *dd;
int count, max;
NSString *currentFile, *filename;
if (!systemAdditionalJSFiles)
{
systemAdditionalJSFiles = [NSMutableArray new];
dd = [[context activeUser] domainDefaults];
prefsJSFiles = [dd additionalJSFiles];
max = [prefsJSFiles count];
for (count = 0; count < max; count++)
{
currentFile = [prefsJSFiles objectAtIndex: count];
filename = [self urlForResourceFilename: [currentFile stringByTrimmingSpaces]];
[systemAdditionalJSFiles addObject: filename];
}
}
return systemAdditionalJSFiles;
}
- (NSString *) pageCSSURL
{
WOComponent *page;
NSString *filename;
page = [context page];
filename = [NSString stringWithFormat: @"css/%@.css",
NSStringFromClass([page class])];
return [self urlForResourceFilename: filename];
}
- (NSString *) productCSSURL
{
WOComponent *page;
NSString *filename;
page = [context page];
filename = [NSString stringWithFormat: @"css/%@.css",
[page frameworkName]];
return [self urlForResourceFilename: filename];
}
- (NSString *) thisPageURL
{
return [[context page] uri];
}
- (BOOL) hasPageSpecificCSS
{
return ([[self pageCSSURL] length] > 0);
}
- (BOOL) hasProductSpecificCSS
{
return ([[self productCSSURL] length] > 0);
}
- (void) setToolbar: (NSString *) newToolbar
{
ASSIGN (toolbar, newToolbar);
}
- (NSString *) toolbar
{
return toolbar;
}
- (BOOL) isSuperUser
{
SOGoUser *user;
user = [context activeUser];
return ([user respondsToSelector: @selector (isSuperUser)]
&& [user isSuperUser]);
}
- (BOOL) usesCASAuthentication
{
SOGoSystemDefaults *sd;
sd = [SOGoSystemDefaults sharedSystemDefaults];
return [[sd authenticationType] isEqualToString: @"cas"];
}
- (BOOL) usesOpenIdAuthentication
{
SOGoSystemDefaults *sd;
sd = [SOGoSystemDefaults sharedSystemDefaults];
return [[sd authenticationType] isEqualToString: @"openid"];
}
- (BOOL) usesSAML2Authenticationx
{
SOGoSystemDefaults *sd;
sd = [SOGoSystemDefaults sharedSystemDefaults];
return [[sd authenticationType] isEqualToString: @"saml2"];
}
- (NSString *) userIdentification
{
NSString *v;
/* The "identification" term is used in the human sense here. */
if ([[context activeUser] primaryIdentity]
&& [[[context activeUser] primaryIdentity] objectForKey:@"fullName"]
&& [[[[[context activeUser] primaryIdentity] objectForKey:@"fullName"] stringByReplacingOccurrencesOfString:@" " withString:@""] length] > 0) {
v = [[[context activeUser] primaryIdentity] objectForKey:@"fullName"];
} else {
v = [[context activeUser] cn];
}
return (v ? v : @"");
}
- (NSString *) userEmail
{
NSDictionary *identity;
identity = [[context activeUser] defaultIdentity];
return [identity objectForKey: @"email"];
}
- (BOOL) canLogoff
{
BOOL canLogoff;
id auth;
SOGoSystemDefaults *sd;
NSString *authType, *login, *loginDomain;
NSRange r;
auth = [[self clientObject] authenticatorInContext: context];
if ([auth respondsToSelector: @selector (cookieNameInContext:)])
{
sd = [SOGoSystemDefaults sharedSystemDefaults];
login = [[context activeUser] login];
r = [login rangeOfString: @"@"];
if (r.location != NSNotFound)
loginDomain = [login substringFromIndex: r.location+1];
else
loginDomain = nil;
if(loginDomain && [sd doesLoginTypeByDomain])
authType = [sd getLoginTypeForDomain: loginDomain];
else
authType = [sd authenticationType];
if ([authType isEqualToString: @"cas"])
canLogoff = [sd CASLogoutEnabled];
else if ([authType isEqualToString: @"saml2"])
canLogoff = [sd SAML2LogoutEnabled];
else if ([authType isEqualToString: @"openid"])
canLogoff = [sd openIdLogoutEnabled: loginDomain];
else
canLogoff = [[auth cookieNameInContext: context] length] > 0;
}
else
canLogoff = NO;
return canLogoff;
}
- (NSString *) userLanguage
{
SOGoUserDefaults *ud;
ud = [[context activeUser] userDefaults];
return [ud language];
}
/* UserDefaults, UserSettings */
- (NSString *) _dictionaryWithKeys: (NSArray *) keys
fromSource: (SOGoDefaultsSource *) source
{
NSString *key;
int count, max;
NSMutableDictionary *dict;
NSNull *nsNull;
id value;
nsNull = [NSNull null];
max = [keys count];
dict = [NSMutableDictionary dictionaryWithCapacity: max];
for (count = 0; count < max; count++)
{
key = [keys objectAtIndex: count];
value = [source objectForKey: key];
if (!value)
value = nsNull;
[dict setObject: value forKey: key];
}
return [dict jsonRepresentation];
}
- (void) setUserDefaultsKeys: (NSString *) newKeys
{
[udKeys release];
udKeys = [[newKeys componentsSeparatedByString: @","] trimmedComponents];
[udKeys retain];
}
- (BOOL) hasUserDefaultsKeys
{
return ([udKeys count] > 0);
}
- (NSString *) userDefaults
{
SOGoUserDefaults *ud;
ud = [[context activeUser] userDefaults];
return [self _dictionaryWithKeys: udKeys fromSource: ud];
}
- (void) setUserSettingsKeys: (NSString *) newKeys
{
[usKeys release];
usKeys = [[newKeys componentsSeparatedByString: @","] trimmedComponents];
[usKeys retain];
}
- (BOOL) hasUserSettingsKeys
{
return ([usKeys count] > 0);
}
- (NSString *) userSettings
{
SOGoUserSettings *us;
us = [[context activeUser] userSettings];
return [self _dictionaryWithKeys: usKeys fromSource: us];
}
/* browser/os identification */
- (BOOL) disableInk
{
SOGoUserDefaults *ud;
WEClientCapabilities *cc;
ud = [[context activeUser] userDefaults];
cc = [[context request] clientCapabilities];
return [[cc userAgentType] isEqualToString: @"IE"] ||
[[ud animationMode] isEqualToString: @"limited"] ||
[[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;
cc = [[context request] clientCapabilities];
//NSLog(@"Browser = %@", [cc description]);
//NSLog(@"User agent = %@", [cc userAgent]);
//NSLog(@"Browser major version = %i", [cc majorVersion]);
return (([[cc userAgentType] isEqualToString: @"IE"]
&& [cc majorVersion] >= 7)
|| ([[cc userAgentType] isEqualToString: @"Mozilla"]
&& [cc majorVersion] >= 5)
|| ([[cc userAgentType] isEqualToString: @"Safari"]
&& [cc majorVersion] >= 3)
|| ([[cc userAgentType] isEqualToString: @"Konqueror"]
&& [cc majorVersion] >= 4)
|| [[cc userAgentType] isEqualToString: @"Opera"]
);
}
@end /* UIxPageFrame */
@interface UIxSidenavToolbarTemplate : UIxComponent
@end
@implementation UIxSidenavToolbarTemplate
@end