44
55namespace Icinga \Util ;
66
7+ use Icinga \Application \Hook ;
8+ use Icinga \Application \Hook \CspDirectiveHook ;
9+ use Icinga \Application \Icinga ;
10+ use Icinga \Application \Logger ;
11+ use Icinga \Authentication \Auth ;
12+ use Icinga \Data \ConfigObject ;
13+ use Icinga \User ;
714use Icinga \Web \Response ;
815use Icinga \Web \Window ;
916use RuntimeException ;
@@ -45,17 +52,131 @@ private function __construct()
4552 */
4653 public static function addHeader (Response $ response ): void
4754 {
55+ $ user = Auth::getInstance ()->getUser ();
56+ $ header = static ::getContentSecurityPolicy ($ user );
57+ Logger::debug ("Setting Content-Security-Policy header for user {$ user ->getUsername ()} to $ header " );
58+ $ response ->setHeader ('Content-Security-Policy ' , $ header , true );
59+ }
60+
61+ /**
62+ * Get the Content-Security-Policy for a specific user.
63+ *
64+ * @param User $user
65+ *
66+ * @throws RuntimeException If no nonce set for CSS
67+ *
68+ * @return string Returns the generated header value.
69+ */
70+ public static function getContentSecurityPolicy (User $ user ): string {
4871 $ csp = static ::getInstance ();
4972
5073 if (empty ($ csp ->styleNonce )) {
5174 throw new RuntimeException ('No nonce set for CSS ' );
5275 }
5376
54- $ response ->setHeader (
55- 'Content-Security-Policy ' ,
56- "script-src 'self'; style-src 'self' 'nonce- $ csp ->styleNonce '; " ,
57- true
58- );
77+ // These are the default directives that should always be enforced. 'self' is valid for all
78+ // directives and will therefor not be listed here.
79+ $ cspDirectives = [
80+ 'style-src ' => ["'nonce- {$ csp ->styleNonce }' " ],
81+ 'font-src ' => ["data: " ],
82+ 'img-src ' => ["data: " ],
83+ 'frame-src ' => []
84+ ];
85+
86+ // Whitelist the hosts in the custom NavigationItems configured for the user,
87+ // so that the iframes can be rendered properly.
88+ /** @var array<ConfigObject> $navigationItems */
89+ $ navigationItems = NavigationItemHelper::fetchUserNavigationItems ($ user );
90+ foreach ($ navigationItems as $ navigationItem ) {
91+
92+ // Skip the host if the link gets opened in a new window.
93+ if ($ navigationItem ->get ("target " , "" ) === "_blank " ) {
94+ continue ;
95+ }
96+
97+ $ name = $ navigationItem ->get ("name " , "" );
98+ $ url = $ navigationItem ->get ("url " , "" );
99+
100+ $ scheme = parse_url ($ url , PHP_URL_SCHEME );
101+ $ host = parse_url ($ url , PHP_URL_HOST );
102+
103+ if ($ host === null || !static ::validateCspPolicy ("NavigationItem ' $ name' " , "frame-src " , $ host )) {
104+ continue ;
105+ }
106+
107+ $ policy = $ host ;
108+ if ($ scheme !== null ) {
109+ $ policy = "$ scheme:// $ host " ;
110+ }
111+
112+ $ cspDirectives ['frame-src ' ][] = $ policy ;
113+ }
114+
115+ // Allow modules to add their own csp directives in a limited fashion.
116+ /** @var CspDirectiveHook $hook */
117+ foreach (Hook::all ('CspDirective ' ) as $ hook ) {
118+ foreach ($ hook ->getCspDirectives () as $ directive => $ policies ) {
119+
120+ // policy names contain only lowercase letters and '-'. Reject anything else.
121+ if (!preg_match ('|^[a-z\-]+$| ' , $ directive )) {
122+ $ errorSource = get_class ($ hook );
123+ Logger::debug ("$ errorSource: Invalid CSP directive found: $ directive " );
124+ continue ;
125+ }
126+
127+ // The default-src can only ever be 'self'. Disallow any updates to it.
128+ if ($ directive === "default-src " ) {
129+ $ errorSource = get_class ($ hook );
130+ Logger::debug ("$ errorSource: Changing default-src is forbidden. " );
131+ continue ;
132+ }
133+
134+ $ cspDirectives [$ directive ] = $ cspDirectives [$ directive ] ?? [];
135+ foreach ($ policies as $ policy ) {
136+ $ source = get_class ($ hook );
137+ if (!static ::validateCspPolicy ($ source , $ directive , $ policy )) {
138+ continue ;
139+ }
140+
141+ $ cspDirectives [$ directive ][] = $ policy ;
142+ }
143+ }
144+ }
145+
146+ $ header = "default-src 'self'; " ;
147+ foreach ($ cspDirectives as $ directive => $ policies ) {
148+ if (!empty ($ policies )) {
149+ $ header .= ' ' . implode (' ' , array_merge ([$ directive , "'self' " ], array_unique ($ policies ))) . '; ' ;
150+ }
151+ }
152+
153+ return $ header ;
154+ }
155+
156+ public static function validateCspPolicy (string $ source , string $ directive , string $ policy ): bool {
157+ // We accept the following policies:
158+ // 1. Hosts: Modules can whitelist certain domains as sources for the CSP header directives.
159+ // - A host can have a specific scheme (http or https).
160+ // - A host can whitelist all subdomains with *
161+ // - A host can contain all alphanumeric characters as well as '+', '-', '_', '.', and ':'
162+ // 2. Nonce: Modules are allowed to specify custom nonce for some directives.
163+ // - A nonce is enclosed in single-quotes: "'"
164+ // - A nonce begins with 'nonce-' followed by at least 22 significant characters of base64 encoded data.
165+ // as recommended by the standard: https://content-security-policy.com/nonce/
166+ if (! preg_match ("/^((https?:\/\/)?\*?[a-zA-Z0-9+._\-:]+|'nonce-[a-zA-Z0-9+\/]{22,}={0,3}')$/ " , $ policy )) {
167+ Logger::debug ("$ source: Invalid CSP policy found: $ directive $ policy " );
168+ return false ;
169+ }
170+
171+ // We refuse all overly aggressive whitelisting by default. This includes:
172+ // 1. Whitelisting all Hosts with '*'
173+ // 2. Whitelisting all Hosts in a tld, e.g. 'http://*.com'
174+ if (preg_match ('|\*(\.[a-zA-Z]+)?$| ' , $ directive )) {
175+ Logger::debug ("$ source: Disallowing whitelisting all hosts. $ directive " );
176+ return false ;
177+ }
178+
179+ return true ;
59180 }
60181
61182 /**
@@ -67,9 +188,10 @@ public static function addHeader(Response $response): void
67188 public static function createNonce (): void
68189 {
69190 $ csp = static ::getInstance ();
70- $ csp ->styleNonce = base64_encode (random_bytes (16 ));
71-
72- Window::getInstance ()->getSessionNamespace ('csp ' )->set ('style_nonce ' , $ csp ->styleNonce );
191+ if ($ csp ->styleNonce === null ) {
192+ $ csp ->styleNonce = base64_encode (random_bytes (16 ));
193+ Window::getInstance ()->getSessionNamespace ('csp ' )->set ('style_nonce ' , $ csp ->styleNonce );
194+ }
73195 }
74196
75197 /**
@@ -79,7 +201,10 @@ public static function createNonce(): void
79201 */
80202 public static function getStyleNonce (): ?string
81203 {
82- return static ::getInstance ()->styleNonce ;
204+ if (Icinga::app ()->isWeb ()) {
205+ return static ::getInstance ()->styleNonce ;
206+ }
207+ return null ;
83208 }
84209
85210 /**
0 commit comments