Skip to content

Commit 969e600

Browse files
Merge pull request #14156 from vojtechszocs/add-initial-csp
CONSOLE-4263: Add initial Content Security Policy for Console web application
2 parents 286a280 + f19c620 commit 969e600

File tree

4 files changed

+110
-10
lines changed

4 files changed

+110
-10
lines changed

frontend/packages/console-dynamic-plugin-sdk/README.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,29 @@ import { MonitoringIcon } from '@patternfly/react-icons/dist/esm/icons/monitorin
174174
import { MonitoringIcon } from '@patternfly/react-icons';
175175
```
176176

177+
## Content Security Policy
178+
179+
Console application uses [Content Security Policy](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP)
180+
(CSP) to detect and mitigate certain types of attacks. By default, the list of allowed
181+
[CSP sources](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources)
182+
includes the document origin `'self'` and Console webpack dev server when running off-cluster.
183+
184+
All dynamic plugin assets _should_ be loaded using `/api/plugins/<plugin-name>` Bridge endpoint which
185+
matches the `'self'` CSP source of Console application.
186+
187+
See `cspSources` and `cspDirectives` in
188+
[`pkg/server/server.go`](https://github.com/openshift/console/blob/master/pkg/server/server.go)
189+
for details on the current Console CSP implementation.
190+
191+
### Changes in Console CSP
192+
193+
This section documents notable changes in the Console Content Security Policy.
194+
195+
#### Console 4.18.x
196+
197+
Console CSP is deployed in report-only mode. CSP violations will be logged in the browser console
198+
but the associated CSP directives will not be enforced.
199+
177200
## Plugin metadata
178201

179202
Older versions of webpack `ConsoleRemotePlugin` assumed that the plugin metadata is specified via

frontend/public/components/app.jsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ initI18n();
8080
// Only linkify url strings beginning with a proper protocol scheme.
8181
linkify.set({ fuzzyLink: false });
8282

83+
const pluginAssetBaseURL = `${document.baseURI}api/plugins/`;
84+
85+
const getPluginNameFromResourceURL = (url) =>
86+
url?.startsWith(pluginAssetBaseURL)
87+
? url.substring(pluginAssetBaseURL.length).split('/')[0]
88+
: null;
89+
8390
const EnhancedProvider = ({ provider: ContextProvider, useValueHook, children }) => {
8491
const value = useValueHook();
8592
return <ContextProvider value={value}>{children}</ContextProvider>;
@@ -132,13 +139,64 @@ const App = (props) => {
132139
}
133140
}, []);
134141

142+
const onCSPViolation = React.useCallback((event) => {
143+
// eslint-disable-next-line no-console
144+
console.warn('Content Security Policy violation detected', event);
145+
146+
// https://developer.mozilla.org/en-US/docs/Web/API/SecurityPolicyViolationEvent
147+
const cspReportObject = _.pick(event, [
148+
// The URI of the resource that was blocked because it violates a policy.
149+
'blockedURI',
150+
// The column number in the document or worker at which the violation occurred.
151+
'columnNumber',
152+
// Whether the user agent is configured to enforce or just report the policy violation.
153+
'disposition',
154+
// The URI of the document or worker in which the violation occurred.
155+
'documentURI',
156+
// The directive that was violated.
157+
'effectiveDirective',
158+
// The line number in the document or worker at which the violation occurred.
159+
'lineNumber',
160+
// The policy whose enforcement caused the violation.
161+
'originalPolicy',
162+
// The URL for the referrer of the resources whose policy was violated, or null.
163+
'referrer',
164+
// A sample of the resource that caused the violation, usually the first 40 characters.
165+
// This will only be populated if the resource is an inline script, event handler or style.
166+
'sample',
167+
// If the violation occurred as a result of a script, this will be the URL of the script.
168+
'sourceFile',
169+
// HTTP status code of the document or worker in which the violation occurred.
170+
'statusCode',
171+
]);
172+
173+
// Attempt to infer Console plugin name from CSP report object
174+
const pluginName =
175+
getPluginNameFromResourceURL(cspReportObject.blockedURI) ||
176+
getPluginNameFromResourceURL(cspReportObject.sourceFile);
177+
178+
if (pluginName) {
179+
// eslint-disable-next-line no-console
180+
console.warn(
181+
`Content Security Policy violation seems to originate from plugin ${pluginName}`,
182+
);
183+
}
184+
}, []);
185+
135186
React.useEffect(() => {
136187
window.addEventListener('resize', onResize);
137188
return () => {
138189
window.removeEventListener('resize', onResize);
139190
};
140191
}, [onResize]);
141192

193+
React.useEffect(() => {
194+
document.addEventListener('securitypolicyviolation', onCSPViolation);
195+
return () => {
196+
document.removeEventListener('securitypolicyviolation', onCSPViolation);
197+
};
198+
}, [onCSPViolation]);
199+
142200
React.useLayoutEffect(() => {
143201
// Prevent infinite loop in case React Router decides to destroy & recreate the component (changing key)
144202
const oldLocation = _.omit(prevLocation, ['key']);

pkg/proxy/proxy.go

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import (
44
"crypto/tls"
55
"encoding/base64"
66
"fmt"
7-
"log"
87
"net"
98
"net/http"
109
"net/http/httputil"
@@ -112,8 +111,7 @@ func CopyRequestHeaders(originalRequest, newRequest *http.Request) {
112111
}
113112

114113
func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
115-
116-
if klog.V(3).Enabled() {
114+
if klog.V(4).Enabled() {
117115
klog.Infof("PROXY: %#q\n", SingleJoiningSlash(p.config.Endpoint.String(), r.URL.Path))
118116
}
119117

@@ -231,13 +229,13 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
231229
errMsg := fmt.Sprintf("Failed to dial backend: '%v'", err)
232230
statusCode := http.StatusBadGateway
233231
if resp == nil || resp.StatusCode == 0 {
234-
log.Println(errMsg)
232+
klog.Error(errMsg)
235233
} else {
236234
statusCode = resp.StatusCode
237235
if resp.Request == nil {
238-
log.Printf("%s Status: '%v' (no request object)", errMsg, resp.Status)
236+
klog.Errorf("%s Status: '%v' (no request object)", errMsg, resp.Status)
239237
} else {
240-
log.Printf("%s Status: '%v' URL: '%v'", errMsg, resp.Status, resp.Request.URL)
238+
klog.Errorf("%s Status: '%v' URL: '%v'", errMsg, resp.Status, resp.Request.URL)
241239
}
242240
}
243241
http.Error(w, errMsg, statusCode)
@@ -250,23 +248,23 @@ func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
250248
CheckOrigin: func(r *http.Request) bool {
251249
origin := r.Header["Origin"]
252250
if p.config.Origin == "" {
253-
log.Printf("CheckOrigin: Proxy has no configured Origin. Allowing origin %v to %v", origin, r.URL)
251+
klog.Infof("CheckOrigin: Proxy has no configured Origin. Allowing origin %v to %v", origin, r.URL)
254252
return true
255253
}
256254
if len(origin) == 0 {
257-
log.Printf("CheckOrigin: No origin header. Denying request to %v", r.URL)
255+
klog.Warningf("CheckOrigin: No origin header. Denying request to %v", r.URL)
258256
return false
259257
}
260258
if p.config.Origin == origin[0] {
261259
return true
262260
}
263-
log.Printf("CheckOrigin '%v' != '%v'", p.config.Origin, origin[0])
261+
klog.Warningf("CheckOrigin '%v' != '%v'", p.config.Origin, origin[0])
264262
return false
265263
},
266264
}
267265
frontend, err := upgrader.Upgrade(w, r, nil)
268266
if err != nil {
269-
log.Printf("Failed to upgrade websocket to client: '%v'", err)
267+
klog.Errorf("Failed to upgrade websocket to client: '%v'", err)
270268
return
271269
}
272270

pkg/server/server.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -675,6 +675,27 @@ func (s *Server) indexHandler(w http.ResponseWriter, r *http.Request) {
675675
return
676676
}
677677

678+
// This Content Security Policy (CSP) applies to Console web application resources.
679+
// Console CSP is deployed in report-only mode via "Content-Security-Policy-Report-Only" header.
680+
// See https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP for details on CSP specification.
681+
cspSources := "'self'"
682+
if s.K8sMode == "off-cluster" {
683+
// Console local development involves a webpack server running on port 8080
684+
cspSources = cspSources + " http://localhost:8080 ws://localhost:8080"
685+
}
686+
cspDirectives := []string{
687+
fmt.Sprintf("default-src %s", cspSources),
688+
fmt.Sprintf("base-uri %s", cspSources),
689+
fmt.Sprintf("img-src %s data:", cspSources),
690+
fmt.Sprintf("font-src %s data:", cspSources),
691+
fmt.Sprintf("script-src %s 'unsafe-eval'", cspSources),
692+
fmt.Sprintf("style-src %s 'unsafe-inline'", cspSources),
693+
"frame-src 'none'",
694+
"frame-ancestors 'none'",
695+
"object-src 'none'",
696+
}
697+
w.Header().Set("Content-Security-Policy-Report-Only", strings.Join(cspDirectives, "; "))
698+
678699
plugins := make([]string, 0, len(s.EnabledConsolePlugins))
679700
for plugin := range s.EnabledConsolePlugins {
680701
plugins = append(plugins, plugin)

0 commit comments

Comments
 (0)