Show More
@@ -0,0 +1,129 b'' | |||||
|
1 | #require serve | |||
|
2 | ||||
|
3 | $ cat > web.conf << EOF | |||
|
4 | > [paths] | |||
|
5 | > / = $TESTTMP/* | |||
|
6 | > EOF | |||
|
7 | ||||
|
8 | $ hg init repo1 | |||
|
9 | $ cd repo1 | |||
|
10 | $ touch foo | |||
|
11 | $ hg -q commit -A -m initial | |||
|
12 | $ cd .. | |||
|
13 | ||||
|
14 | $ hg serve -p $HGPORT -d --pid-file=hg.pid --web-conf web.conf | |||
|
15 | $ cat hg.pid >> $DAEMON_PIDS | |||
|
16 | ||||
|
17 | repo index should not send Content-Security-Policy header by default | |||
|
18 | ||||
|
19 | $ get-with-headers.py --headeronly localhost:$HGPORT '' content-security-policy etag | |||
|
20 | 200 Script output follows | |||
|
21 | ||||
|
22 | static page should not send CSP by default | |||
|
23 | ||||
|
24 | $ get-with-headers.py --headeronly localhost:$HGPORT static/mercurial.js content-security-policy etag | |||
|
25 | 200 Script output follows | |||
|
26 | ||||
|
27 | repo page should not send CSP by default, should send ETag | |||
|
28 | ||||
|
29 | $ get-with-headers.py --headeronly localhost:$HGPORT repo1 content-security-policy etag | |||
|
30 | 200 Script output follows | |||
|
31 | etag: W/"*" (glob) | |||
|
32 | ||||
|
33 | $ killdaemons.py | |||
|
34 | ||||
|
35 | Configure CSP without nonce | |||
|
36 | ||||
|
37 | $ cat >> web.conf << EOF | |||
|
38 | > [web] | |||
|
39 | > csp = script-src https://example.com/ 'unsafe-inline' | |||
|
40 | > EOF | |||
|
41 | ||||
|
42 | $ hg serve -p $HGPORT -d --pid-file=hg.pid --web-conf web.conf | |||
|
43 | $ cat hg.pid > $DAEMON_PIDS | |||
|
44 | ||||
|
45 | repo index should send Content-Security-Policy header when enabled | |||
|
46 | ||||
|
47 | $ get-with-headers.py --headeronly localhost:$HGPORT '' content-security-policy etag | |||
|
48 | 200 Script output follows | |||
|
49 | content-security-policy: script-src https://example.com/ 'unsafe-inline' | |||
|
50 | ||||
|
51 | static page should send CSP when enabled | |||
|
52 | ||||
|
53 | $ get-with-headers.py --headeronly localhost:$HGPORT static/mercurial.js content-security-policy etag | |||
|
54 | 200 Script output follows | |||
|
55 | content-security-policy: script-src https://example.com/ 'unsafe-inline' | |||
|
56 | ||||
|
57 | repo page should send CSP by default, include etag w/o nonce | |||
|
58 | ||||
|
59 | $ get-with-headers.py --headeronly localhost:$HGPORT repo1 content-security-policy etag | |||
|
60 | 200 Script output follows | |||
|
61 | content-security-policy: script-src https://example.com/ 'unsafe-inline' | |||
|
62 | etag: W/"*" (glob) | |||
|
63 | ||||
|
64 | nonce should not be added to html if CSP doesn't use it | |||
|
65 | ||||
|
66 | $ get-with-headers.py localhost:$HGPORT repo1/graph/tip | egrep 'content-security-policy|<script' | |||
|
67 | <script type="text/javascript" src="/repo1/static/mercurial.js"></script> | |||
|
68 | <!--[if IE]><script type="text/javascript" src="/repo1/static/excanvas.js"></script><![endif]--> | |||
|
69 | <script type="text/javascript"> | |||
|
70 | <script type="text/javascript"> | |||
|
71 | ||||
|
72 | Configure CSP with nonce | |||
|
73 | ||||
|
74 | $ killdaemons.py | |||
|
75 | $ cat >> web.conf << EOF | |||
|
76 | > csp = image-src 'self'; script-src https://example.com/ 'nonce-%nonce%' | |||
|
77 | > EOF | |||
|
78 | ||||
|
79 | $ hg serve -p $HGPORT -d --pid-file=hg.pid --web-conf web.conf | |||
|
80 | $ cat hg.pid > $DAEMON_PIDS | |||
|
81 | ||||
|
82 | nonce should be substituted in CSP header | |||
|
83 | ||||
|
84 | $ get-with-headers.py --headeronly localhost:$HGPORT '' content-security-policy etag | |||
|
85 | 200 Script output follows | |||
|
86 | content-security-policy: image-src 'self'; script-src https://example.com/ 'nonce-*' (glob) | |||
|
87 | ||||
|
88 | nonce should be included in CSP for static pages | |||
|
89 | ||||
|
90 | $ get-with-headers.py --headeronly localhost:$HGPORT static/mercurial.js content-security-policy etag | |||
|
91 | 200 Script output follows | |||
|
92 | content-security-policy: image-src 'self'; script-src https://example.com/ 'nonce-*' (glob) | |||
|
93 | ||||
|
94 | repo page should have nonce, no ETag | |||
|
95 | ||||
|
96 | $ get-with-headers.py --headeronly localhost:$HGPORT repo1 content-security-policy etag | |||
|
97 | 200 Script output follows | |||
|
98 | content-security-policy: image-src 'self'; script-src https://example.com/ 'nonce-*' (glob) | |||
|
99 | ||||
|
100 | nonce should be added to html when used | |||
|
101 | ||||
|
102 | $ get-with-headers.py localhost:$HGPORT repo1/graph/tip content-security-policy | egrep 'content-security-policy|<script' | |||
|
103 | content-security-policy: image-src 'self'; script-src https://example.com/ 'nonce-*' (glob) | |||
|
104 | <script type="text/javascript" src="/repo1/static/mercurial.js"></script> | |||
|
105 | <!--[if IE]><script type="text/javascript" src="/repo1/static/excanvas.js"></script><![endif]--> | |||
|
106 | <script type="text/javascript" nonce="*"> (glob) | |||
|
107 | <script type="text/javascript" nonce="*"> (glob) | |||
|
108 | ||||
|
109 | hgweb_mod w/o hgwebdir works as expected | |||
|
110 | ||||
|
111 | $ killdaemons.py | |||
|
112 | ||||
|
113 | $ hg -R repo1 serve -p $HGPORT -d --pid-file=hg.pid --config "web.csp=image-src 'self'; script-src https://example.com/ 'nonce-%nonce%'" | |||
|
114 | $ cat hg.pid > $DAEMON_PIDS | |||
|
115 | ||||
|
116 | static page sends CSP | |||
|
117 | ||||
|
118 | $ get-with-headers.py --headeronly localhost:$HGPORT static/mercurial.js content-security-policy etag | |||
|
119 | 200 Script output follows | |||
|
120 | content-security-policy: image-src 'self'; script-src https://example.com/ 'nonce-*' (glob) | |||
|
121 | ||||
|
122 | nonce included in <script> and headers | |||
|
123 | ||||
|
124 | $ get-with-headers.py localhost:$HGPORT graph/tip content-security-policy | egrep 'content-security-policy|<script' | |||
|
125 | content-security-policy: image-src 'self'; script-src https://example.com/ 'nonce-*' (glob) | |||
|
126 | <script type="text/javascript" src="/static/mercurial.js"></script> | |||
|
127 | <!--[if IE]><script type="text/javascript" src="/static/excanvas.js"></script><![endif]--> | |||
|
128 | <script type="text/javascript" nonce="*"> (glob) | |||
|
129 | <script type="text/javascript" nonce="*"> (glob) |
@@ -2111,6 +2111,20 b' The full set of options is:' | |||||
2111 | Name or email address of the person in charge of the repository. |
|
2111 | Name or email address of the person in charge of the repository. | |
2112 | (default: ui.username or ``$EMAIL`` or "unknown" if unset or empty) |
|
2112 | (default: ui.username or ``$EMAIL`` or "unknown" if unset or empty) | |
2113 |
|
2113 | |||
|
2114 | ``csp`` | |||
|
2115 | Send a ``Content-Security-Policy`` HTTP header with this value. | |||
|
2116 | ||||
|
2117 | The value may contain a special string ``%nonce%``, which will be replaced | |||
|
2118 | by a randomly-generated one-time use value. If the value contains | |||
|
2119 | ``%nonce%``, ``web.cache`` will be disabled, as caching undermines the | |||
|
2120 | one-time property of the nonce. This nonce will also be inserted into | |||
|
2121 | ``<script>`` elements containing inline JavaScript. | |||
|
2122 | ||||
|
2123 | Note: lots of HTML content sent by the server is derived from repository | |||
|
2124 | data. Please consider the potential for malicious repository data to | |||
|
2125 | "inject" itself into generated HTML content as part of your security | |||
|
2126 | threat model. | |||
|
2127 | ||||
2114 | ``deny_push`` |
|
2128 | ``deny_push`` | |
2115 | Whether to deny pushing to the repository. If empty or not set, |
|
2129 | Whether to deny pushing to the repository. If empty or not set, | |
2116 | push is not denied. If the special value ``*``, all remote users are |
|
2130 | push is not denied. If the special value ``*``, all remote users are |
@@ -8,9 +8,11 b'' | |||||
8 |
|
8 | |||
9 | from __future__ import absolute_import |
|
9 | from __future__ import absolute_import | |
10 |
|
10 | |||
|
11 | import base64 | |||
11 | import errno |
|
12 | import errno | |
12 | import mimetypes |
|
13 | import mimetypes | |
13 | import os |
|
14 | import os | |
|
15 | import uuid | |||
14 |
|
16 | |||
15 | from .. import ( |
|
17 | from .. import ( | |
16 | encoding, |
|
18 | encoding, | |
@@ -199,3 +201,22 b' def caching(web, req):' | |||||
199 | if req.env.get('HTTP_IF_NONE_MATCH') == tag: |
|
201 | if req.env.get('HTTP_IF_NONE_MATCH') == tag: | |
200 | raise ErrorResponse(HTTP_NOT_MODIFIED) |
|
202 | raise ErrorResponse(HTTP_NOT_MODIFIED) | |
201 | req.headers.append(('ETag', tag)) |
|
203 | req.headers.append(('ETag', tag)) | |
|
204 | ||||
|
205 | def cspvalues(ui): | |||
|
206 | """Obtain the Content-Security-Policy header and nonce value. | |||
|
207 | ||||
|
208 | Returns a 2-tuple of the CSP header value and the nonce value. | |||
|
209 | ||||
|
210 | First value is ``None`` if CSP isn't enabled. Second value is ``None`` | |||
|
211 | if CSP isn't enabled or if the CSP header doesn't need a nonce. | |||
|
212 | """ | |||
|
213 | # Don't allow untrusted CSP setting since it be disable protections | |||
|
214 | # from a trusted/global source. | |||
|
215 | csp = ui.config('web', 'csp', untrusted=False) | |||
|
216 | nonce = None | |||
|
217 | ||||
|
218 | if csp and '%nonce%' in csp: | |||
|
219 | nonce = base64.urlsafe_b64encode(uuid.uuid4().bytes).rstrip('=') | |||
|
220 | csp = csp.replace('%nonce%', nonce) | |||
|
221 | ||||
|
222 | return csp, nonce |
@@ -19,6 +19,7 b' from .common import (' | |||||
19 | HTTP_OK, |
|
19 | HTTP_OK, | |
20 | HTTP_SERVER_ERROR, |
|
20 | HTTP_SERVER_ERROR, | |
21 | caching, |
|
21 | caching, | |
|
22 | cspvalues, | |||
22 | permhooks, |
|
23 | permhooks, | |
23 | ) |
|
24 | ) | |
24 | from .request import wsgirequest |
|
25 | from .request import wsgirequest | |
@@ -115,6 +116,8 b' class requestcontext(object):' | |||||
115 | # of the request. |
|
116 | # of the request. | |
116 | self.websubtable = app.websubtable |
|
117 | self.websubtable = app.websubtable | |
117 |
|
118 | |||
|
119 | self.csp, self.nonce = cspvalues(self.repo.ui) | |||
|
120 | ||||
118 | # Trust the settings from the .hg/hgrc files by default. |
|
121 | # Trust the settings from the .hg/hgrc files by default. | |
119 | def config(self, section, name, default=None, untrusted=True): |
|
122 | def config(self, section, name, default=None, untrusted=True): | |
120 | return self.repo.ui.config(section, name, default, |
|
123 | return self.repo.ui.config(section, name, default, | |
@@ -201,6 +204,7 b' class requestcontext(object):' | |||||
201 | 'sessionvars': sessionvars, |
|
204 | 'sessionvars': sessionvars, | |
202 | 'pathdef': makebreadcrumb(req.url), |
|
205 | 'pathdef': makebreadcrumb(req.url), | |
203 | 'style': style, |
|
206 | 'style': style, | |
|
207 | 'nonce': self.nonce, | |||
204 | } |
|
208 | } | |
205 | tmpl = templater.templater.frommapfile(mapfile, |
|
209 | tmpl = templater.templater.frommapfile(mapfile, | |
206 | filters={'websub': websubfilter}, |
|
210 | filters={'websub': websubfilter}, | |
@@ -318,6 +322,13 b' class hgweb(object):' | |||||
318 | encoding.encoding = rctx.config('web', 'encoding', encoding.encoding) |
|
322 | encoding.encoding = rctx.config('web', 'encoding', encoding.encoding) | |
319 | rctx.repo.ui.environ = req.env |
|
323 | rctx.repo.ui.environ = req.env | |
320 |
|
324 | |||
|
325 | if rctx.csp: | |||
|
326 | # hgwebdir may have added CSP header. Since we generate our own, | |||
|
327 | # replace it. | |||
|
328 | req.headers = [h for h in req.headers | |||
|
329 | if h[0] != 'Content-Security-Policy'] | |||
|
330 | req.headers.append(('Content-Security-Policy', rctx.csp)) | |||
|
331 | ||||
321 | # work with CGI variables to create coherent structure |
|
332 | # work with CGI variables to create coherent structure | |
322 | # use SCRIPT_NAME, PATH_INFO and QUERY_STRING as well as our REPO_NAME |
|
333 | # use SCRIPT_NAME, PATH_INFO and QUERY_STRING as well as our REPO_NAME | |
323 |
|
334 | |||
@@ -414,7 +425,9 b' class hgweb(object):' | |||||
414 | req.form['cmd'] = [tmpl.cache['default']] |
|
425 | req.form['cmd'] = [tmpl.cache['default']] | |
415 | cmd = req.form['cmd'][0] |
|
426 | cmd = req.form['cmd'][0] | |
416 |
|
427 | |||
417 | if rctx.configbool('web', 'cache', True): |
|
428 | # Don't enable caching if using a CSP nonce because then it wouldn't | |
|
429 | # be a nonce. | |||
|
430 | if rctx.configbool('web', 'cache', True) and not rctx.nonce: | |||
418 | caching(self, req) # sets ETag header or raises NOT_MODIFIED |
|
431 | caching(self, req) # sets ETag header or raises NOT_MODIFIED | |
419 | if cmd not in webcommands.__all__: |
|
432 | if cmd not in webcommands.__all__: | |
420 | msg = 'no such method: %s' % cmd |
|
433 | msg = 'no such method: %s' % cmd |
@@ -19,6 +19,7 b' from .common import (' | |||||
19 | HTTP_NOT_FOUND, |
|
19 | HTTP_NOT_FOUND, | |
20 | HTTP_OK, |
|
20 | HTTP_OK, | |
21 | HTTP_SERVER_ERROR, |
|
21 | HTTP_SERVER_ERROR, | |
|
22 | cspvalues, | |||
22 | get_contact, |
|
23 | get_contact, | |
23 | get_mtime, |
|
24 | get_mtime, | |
24 | ismember, |
|
25 | ismember, | |
@@ -227,8 +228,12 b' class hgwebdir(object):' | |||||
227 | try: |
|
228 | try: | |
228 | self.refresh() |
|
229 | self.refresh() | |
229 |
|
230 | |||
|
231 | csp, nonce = cspvalues(self.ui) | |||
|
232 | if csp: | |||
|
233 | req.headers.append(('Content-Security-Policy', csp)) | |||
|
234 | ||||
230 | virtual = req.env.get("PATH_INFO", "").strip('/') |
|
235 | virtual = req.env.get("PATH_INFO", "").strip('/') | |
231 | tmpl = self.templater(req) |
|
236 | tmpl = self.templater(req, nonce) | |
232 | ctype = tmpl('mimetype', encoding=encoding.encoding) |
|
237 | ctype = tmpl('mimetype', encoding=encoding.encoding) | |
233 | ctype = templater.stringify(ctype) |
|
238 | ctype = templater.stringify(ctype) | |
234 |
|
239 | |||
@@ -466,7 +471,7 b' class hgwebdir(object):' | |||||
466 | sortcolumn=sortcolumn, descending=descending, |
|
471 | sortcolumn=sortcolumn, descending=descending, | |
467 | **dict(sort)) |
|
472 | **dict(sort)) | |
468 |
|
473 | |||
469 | def templater(self, req): |
|
474 | def templater(self, req, nonce): | |
470 |
|
475 | |||
471 | def motd(**map): |
|
476 | def motd(**map): | |
472 | if self.motd is not None: |
|
477 | if self.motd is not None: | |
@@ -510,6 +515,7 b' class hgwebdir(object):' | |||||
510 | "staticurl": staticurl, |
|
515 | "staticurl": staticurl, | |
511 | "sessionvars": sessionvars, |
|
516 | "sessionvars": sessionvars, | |
512 | "style": style, |
|
517 | "style": style, | |
|
518 | "nonce": nonce, | |||
513 | } |
|
519 | } | |
514 | tmpl = templater.templater.frommapfile(mapfile, defaults=defaults) |
|
520 | tmpl = templater.templater.frommapfile(mapfile, defaults=defaults) | |
515 | return tmpl |
|
521 | return tmpl |
@@ -45,7 +45,7 b' graph |' | |||||
45 | <ul id="graphnodes"></ul> |
|
45 | <ul id="graphnodes"></ul> | |
46 | </div> |
|
46 | </div> | |
47 |
|
47 | |||
48 | <script> |
|
48 | <script{if(nonce, ' nonce="{nonce}"')}> | |
49 | <!-- hide script content |
|
49 | <!-- hide script content | |
50 |
|
50 | |||
51 | var data = {jsdata|json}; |
|
51 | var data = {jsdata|json}; | |
@@ -108,7 +108,7 b' graph.render(data);' | |||||
108 | | {changenav%navgraph} |
|
108 | | {changenav%navgraph} | |
109 | </div> |
|
109 | </div> | |
110 |
|
110 | |||
111 | <script type="text/javascript"> |
|
111 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
112 | ajaxScrollInit( |
|
112 | ajaxScrollInit( | |
113 | '{url|urlescape}graph/{rev}?revcount=%next%&style={style}', |
|
113 | '{url|urlescape}graph/{rev}?revcount=%next%&style={style}', | |
114 | {revcount}+60, |
|
114 | {revcount}+60, |
@@ -40,7 +40,7 b' shortlog |' | |||||
40 | {changenav%navshort} |
|
40 | {changenav%navshort} | |
41 | </div> |
|
41 | </div> | |
42 |
|
42 | |||
43 | <script type="text/javascript"> |
|
43 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
44 | ajaxScrollInit( |
|
44 | ajaxScrollInit( | |
45 | '{url|urlescape}shortlog/%next%{sessionvars%urlparameter}', |
|
45 | '{url|urlescape}shortlog/%next%{sessionvars%urlparameter}', | |
46 | '{nextentry%"{node}"}', <!-- NEXTHASH |
|
46 | '{nextentry%"{node}"}', <!-- NEXTHASH |
@@ -40,7 +40,7 b'' | |||||
40 | <ul id="graphnodes"></ul> |
|
40 | <ul id="graphnodes"></ul> | |
41 | </div> |
|
41 | </div> | |
42 |
|
42 | |||
43 | <script> |
|
43 | <script{if(nonce, ' nonce="{nonce}"')}> | |
44 | <!-- hide script content |
|
44 | <!-- hide script content | |
45 |
|
45 | |||
46 | document.getElementById('noscript').style.display = 'none'; |
|
46 | document.getElementById('noscript').style.display = 'none'; | |
@@ -104,7 +104,7 b'' | |||||
104 | | {changenav%navgraph} |
|
104 | | {changenav%navgraph} | |
105 | </div> |
|
105 | </div> | |
106 |
|
106 | |||
107 | <script type="text/javascript"> |
|
107 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
108 | ajaxScrollInit( |
|
108 | ajaxScrollInit( | |
109 | '{url|urlescape}graph/{rev}?revcount=%next%&style={style}', |
|
109 | '{url|urlescape}graph/{rev}?revcount=%next%&style={style}', | |
110 | {revcount}+60, |
|
110 | {revcount}+60, |
@@ -41,7 +41,7 b'' | |||||
41 | {changenav%navshort} |
|
41 | {changenav%navshort} | |
42 | </div> |
|
42 | </div> | |
43 |
|
43 | |||
44 | <script type="text/javascript"> |
|
44 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
45 | ajaxScrollInit( |
|
45 | ajaxScrollInit( | |
46 | '{url|urlescape}shortlog/%next%{sessionvars%urlparameter}', |
|
46 | '{url|urlescape}shortlog/%next%{sessionvars%urlparameter}', | |
47 | '{nextentry%"{node}"}', <!-- NEXTHASH |
|
47 | '{nextentry%"{node}"}', <!-- NEXTHASH |
@@ -59,7 +59,7 b'' | |||||
59 | <ul id="graphnodes"></ul> |
|
59 | <ul id="graphnodes"></ul> | |
60 | </div> |
|
60 | </div> | |
61 |
|
61 | |||
62 | <script type="text/javascript"> |
|
62 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
63 | <!-- hide script content |
|
63 | <!-- hide script content | |
64 |
|
64 | |||
65 | var data = {jsdata|json}; |
|
65 | var data = {jsdata|json}; | |
@@ -121,7 +121,7 b' graph.render(data);' | |||||
121 | | rev {rev}: {changenav%navgraph} |
|
121 | | rev {rev}: {changenav%navgraph} | |
122 | </div> |
|
122 | </div> | |
123 |
|
123 | |||
124 | <script type="text/javascript"> |
|
124 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
125 | ajaxScrollInit( |
|
125 | ajaxScrollInit( | |
126 | '{url|urlescape}graph/{rev}?revcount=%next%&style={style}', |
|
126 | '{url|urlescape}graph/{rev}?revcount=%next%&style={style}', | |
127 | {revcount}+60, |
|
127 | {revcount}+60, |
@@ -72,7 +72,7 b'' | |||||
72 | | rev {rev}: {changenav%navshort} |
|
72 | | rev {rev}: {changenav%navshort} | |
73 | </div> |
|
73 | </div> | |
74 |
|
74 | |||
75 | <script type="text/javascript"> |
|
75 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
76 | ajaxScrollInit( |
|
76 | ajaxScrollInit( | |
77 | '{url|urlescape}shortlog/%next%{sessionvars%urlparameter}', |
|
77 | '{url|urlescape}shortlog/%next%{sessionvars%urlparameter}', | |
78 | '{nextentry%"{node}"}', <!-- NEXTHASH |
|
78 | '{nextentry%"{node}"}', <!-- NEXTHASH |
@@ -36,7 +36,7 b' navigate: <small class="navigate">{chang' | |||||
36 | <ul id="graphnodes"></ul> |
|
36 | <ul id="graphnodes"></ul> | |
37 | </div> |
|
37 | </div> | |
38 |
|
38 | |||
39 | <script type="text/javascript"> |
|
39 | <script type="text/javascript"{if(nonce, ' nonce="{nonce}"')}> | |
40 | <!-- hide script content |
|
40 | <!-- hide script content | |
41 |
|
41 | |||
42 | var data = {jsdata|json}; |
|
42 | var data = {jsdata|json}; |
General Comments 0
You need to be logged in to leave comments.
Login now