[web] implement cross origin resource sharing (CORS) (closes #2491768)

Partial implementation that is enough to get started but leaves out some of the advanced features like caching and non-simple methods and headers.

authorNicolas Chauvat <nicolas.chauvat@logilab.fr>
changeset779b40079d99
branchdefault
phasedraft
hiddenyes
parent revision#146b492cead0 [devtools] add a 'method' argument to RepoAccess.web_request
child revision#1aea2696215e [web] whitespace cleanup in http_headers.py
files modified by this revision
doc/3.19.rst
doc/book/en/admin/instance-config.rst
web/application.py
web/cors.py
web/http_headers.py
web/test/unittest_http.py
web/webconfig.py
# HG changeset patch
# User Nicolas Chauvat <nicolas.chauvat@logilab.fr>
# Date 1394725656 -3600
# Thu Mar 13 16:47:36 2014 +0100
# Node ID 779b40079d9909a313e261d52ae807dec76e980d
# Parent 146b492cead069a3781a01b3d841c4669770de17
[web] implement cross origin resource sharing (CORS) (closes #2491768)

Partial implementation that is enough to get started but leaves out
some of the advanced features like caching and non-simple methods
and headers.

diff --git a/doc/3.19.rst b/doc/3.19.rst
@@ -1,8 +1,15 @@
1  What's new in CubicWeb 3.19?
2  ============================
3 
4 +New functionalities
5 +--------------------
6 +
7 +* implement Cross Origin Resource Sharing (CORS)
8 +  (see `#2491768 <http://www.cubicweb.org/2491768>`_)
9 +
10 +
11  Behaviour Changes
12  -----------------
13 
14  * The anonymous property of Session and Connection are now computed from the
15    related user login. If it matches the ``anonymous-user`` in the config the
diff --git a/doc/book/en/admin/instance-config.rst b/doc/book/en/admin/instance-config.rst
@@ -187,5 +187,40 @@
16  :`navigation.related-limit`:
17      number of related entities to show up on primary entity view
18  :`navigation.combobox-limit`:
19      number of entities unrelated to show up on the drop-down lists of
20      the sight on an editing entity view
21 +
22 +Cross-Origin Resource Sharing
23 +-----------------------------
24 +
25 +CubicWeb provides some support for the CORS_ protocol. For now, the
26 +provided implementation only deals with access to a CubicWeb instance
27 +as a whole. Support for a finer granularity may be considered in the
28 +future.
29 +
30 +Specificities of the provided implementation:
31 +
32 +- ``Access-Control-Allow-Credentials`` is always true
33 +- ``Access-Control-Allow-Origin`` header in response will never be
34 +  ``*``
35 +- ``Access-Control-Expose-Headers`` can be configured globally (see below)
36 +- ``Access-Control-Max-Age`` can be configured globally (see below)
37 +- ``Access-Control-Allow-Methods`` can be configured globally (see below)
38 +- ``Access-Control-Allow-Headers`` can be configured globally (see below)
39 +
40 +
41 +A few parameters can be set to configure the CORS_ capabilities of CubicWeb.
42 +
43 +.. _CORS: http://www.w3.org/TR/cors/
44 +
45 +:`access-control-allow-origin`:
46 +   comma-separated list of allowed origin domains or "*" for any domain
47 +:`access-control-allow-methods`:
48 +   comma-separated list of allowed HTTP methods
49 +:`access-control-max-age`:
50 +   maximum age of cross-origin resource sharing (in seconds)
51 +:`access-control-allow-headers`:
52 +   comma-separated list of allowed HTTP custom headers (used in simple requests)
53 +:`access-control-expose-headers`:
54 +   comma-separated list of allowed HTTP custom headers (used in preflight requests)
55 +
diff --git a/web/application.py b/web/application.py
@@ -34,11 +34,11 @@
56  from cubicweb import (
57      ValidationError, Unauthorized, Forbidden,
58      AuthenticationError, NoSelectableObject,
59      BadConnectionId, CW_EVENT_MANAGER)
60  from cubicweb.repoapi import anonymous_cnx
61 -from cubicweb.web import LOGGER, component
62 +from cubicweb.web import LOGGER, component, cors
63  from cubicweb.web import (
64      StatusResponse, DirectResponse, Redirect, NotFound, LogOut,
65      RemoteCallFailed, InvalidSession, RequestError, PublishException)
66 
67  from cubicweb.web.request import CubicWebRequestBase
@@ -413,10 +413,11 @@
68                  # XXX ensure we don't actually serve content
69                  if not content:
70                      content = self.need_login_content(req)
71          return content
72 
73 +
74      def core_handle(self, req, path):
75          """method called by the main publisher to process <path>
76 
77          should return a string containing the resulting page or raise a
78          `NotFound` exception
@@ -438,18 +439,24 @@
79          tstart = clock()
80          commited = False
81          try:
82              ### standard processing of the request
83              try:
84 +                # apply CORS sanity checks
85 +                cors.process_request(req, self.vreg.config)
86                  ctrlid, rset = self.url_resolver.process(req, path)
87                  try:
88                      controller = self.vreg['controllers'].select(ctrlid, req,
89                                                                   appli=self)
90                  except NoSelectableObject:
91                      raise Unauthorized(req._('not authorized'))
92                  req.update_search_state()
93                  result = controller.publish(rset=rset)
94 +            except cors.CORSPreflight:
95 +                # Return directly an empty 200
96 +                req.status_out = 200
97 +                result = ''
98              except StatusResponse as ex:
99                  warn('[3.16] StatusResponse is deprecated use req.status_out',
100                       DeprecationWarning, stacklevel=2)
101                  result = ex.content
102                  req.status_out = ex.status
diff --git a/web/cors.py b/web/cors.py
@@ -0,0 +1,114 @@
103 +# -*- coding: utf-8 -*-
104 +# copyright 2014 Logilab, PARIS
105 +
106 +"""A set of utility functions to handle CORS requests
107 +
108 +Unless specified, all references in this file are related to:
109 +  http://www.w3.org/TR/cors
110 +
111 +The provided implementation roughtly follows:
112 +  http://www.html5rocks.com/static/images/cors_server_flowchart.png
113 +
114 +See also:
115 +  https://developer.mozilla.org/en-US/docs/HTTP/Access_control_CORS
116 +
117 +"""
118 +
119 +import urlparse
120 +
121 +from cubicweb.web import LOGGER
122 +info = LOGGER.info
123 +
124 +class CORSFailed(Exception):
125 +    """Raised when cross origin resource sharing checks failed"""
126 +class CORSPreflight(Exception):
127 +    """Raised when cross origin resource sharing checks detects the
128 +    request as a valid preflight request"""
129 +
130 +
131 +def process_request(req, config):
132 +    """
133 +    Process a request to apply CORS specification algorithms
134 +
135 +    It checks whether the CORS specifications are respected and
136 +    set corresponding headers to ensure response comply with the
137 +    specifications.
138 +
139 +    In case of non-respect, no CORS-related header is set.
140 +
141 +    Implemented according the flowchart:
142 +    http://www.html5rocks.com/static/images/cors_server_flowchart.png
143 +    """
144 +    if not req.get_header('Origin') or req.base_url().startswith(req.get_header('Origin')):
145 +        # not a CORS request, nothing to do
146 +        return
147 +    try:
148 +        # handle cross origin resource sharing (CORS)
149 +        if req.http_method() == 'OPTIONS':
150 +            if req.get_header('Access-Control-Request-Method'):
151 +                # preflight CORS request
152 +                process_preflight(req, config)
153 +        else: # Simple CORS or actual request
154 +            process_simple(req, config)
155 +    except CORSFailed, exc:
156 +        info('Cross origin resource sharing failed: %s' % exc)
157 +    except CORSPreflight:
158 +        info('Cross origin resource sharing: valid Preflight request %s')
159 +        raise
160 +
161 +def process_preflight(req, config):
162 +    """cross origin resource sharing (preflight)
163 +    Cf http://www.w3.org/TR/cors/#resource-preflight-requests
164 +    """
165 +    origin = check_origin(req, config)
166 +    allowed_methods = set(config['access-control-allow-methods'])
167 +    allowed_headers = set(config['access-control-allow-headers'])
168 +    try:
169 +        method = req.get_header('Access-Control-Request-Method')
170 +    except ValueError:
171 +        raise CORSFailed('Access-Control-Request-Method is incorrect')
172 +    if method not in allowed_methods:
173 +        raise CORSFailed('Method is not allowed')
174 +    try:
175 +        req.get_header('Access-Control-Request-Headers', ())
176 +    except ValueError:
177 +        raise CORSFailed('Access-Control-Request-Headers is incorrect')
178 +    req.set_header('Access-Control-Allow-Methods', allowed_methods, raw=False)
179 +    req.set_header('Access-Control-Allow-Headers', allowed_headers, raw=False)
180 +
181 +    process_common(req, config, origin)
182 +    raise CORSPreflight()
183 +
184 +def process_simple(req, config):
185 +    """Handle the Simple Cross-Origin Request case
186 +    """
187 +    origin = check_origin(req, config)
188 +    exposed_headers = config['access-control-expose-headers']
189 +    if exposed_headers:
190 +        req.set_header('Access-Control-Expose-Headers', exposed_headers, raw=False)
191 +    process_common(req, config, origin)
192 +
193 +def process_common(req, config, origin):
194 +    req.set_header('Access-Control-Allow-Origin', origin)
195 +    # in CW, we always support credential/authentication
196 +    req.set_header('Access-Control-Allow-Credentials', 'true')
197 +
198 +def check_origin(req, config):
199 +    origin = req.get_header('Origin').lower()
200 +    allowed_origins = config.get('access-control-allow-origin')
201 +    if not allowed_origins:
202 +        raise CORSFailed('access-control-allow-origin is not configured')
203 +    if '*' not in allowed_origins and origin not in allowed_origins:
204 +        raise CORSFailed('Origin is not allowed')
205 +    # bit of sanity check; see "6.3 Security"
206 +    myhost = urlparse.urlsplit(req.base_url()).netloc
207 +    host = req.get_header('Host')
208 +    if host != myhost:
209 +        info('cross origin resource sharing detected possible '
210 +             'DNS rebinding attack Host header != host of base_url: '
211 +             '%s != %s' % (host, myhost))
212 +        raise CORSFailed('Host header and hostname do not match')
213 +    # include "Vary: Origin" header (see 6.4)
214 +    req.set_header('Vary', 'Origin')
215 +    return origin
216 +
diff --git a/web/http_headers.py b/web/http_headers.py
@@ -6,10 +6,11 @@
217 
218  import types, time
219  from calendar import timegm
220  import base64
221  import re
222 +import urlparse
223 
224  def dashCapitalize(s):
225      ''' Capitalize a string, making sure to treat - as a word seperator '''
226      return '-'.join([ x.capitalize() for x in s.split('-')])
227 
@@ -378,10 +379,47 @@
228  def last(seq):
229      """Return seq[-1]"""
230 
231      return seq[-1]
232 
233 +def unique(seq):
234 +    '''if seq is not a string, check it's sequence of one element and returns it'''
235 +    if isinstance(seq, basestring):
236 +        seq = [seq]
237 +    if len(seq) != 1:
238 +        raise ValueError('Single value required')
239 +    return seq[0]
240 +
241 +def parseHTTPMethod(method):
242 +    """Ensure a HTTP method is valid according the rfc2616, but extension-method ones"""
243 +    method = method.strip()
244 +    if method not in ("OPTIONS", "GET", "HEAD", "POST", "PUT", "DELETE",
245 +                      "TRACE", "CONNECT"):
246 +        raise ValueError('Unsupported HTTP method %s' % method)
247 +    return method
248 +
249 +def parseAllowOrigin(origin):
250 +    """Ensure origin is a valid URL-base stuff, or null"""
251 +    if origin == 'null':
252 +        return origin
253 +    p = urlparse.urlparse(origin)
254 +    if p.params or p.query or p.username or p.path not in ('', '/'):
255 +        raise ValueError('Incorrect Accept-Control-Allow-Origin value %s' % origin)
256 +    if p.scheme not in ('http', 'https'):
257 +        raise ValueError('Unsupported Accept-Control-Allow-Origin URL scheme %s' % origin)
258 +    if not p.netloc:
259 +        raise ValueError('Accept-Control-Allow-Origin: host name cannot be unset  (%s)' % origin)
260 +    return origin
261 +
262 +def parseAllowCreds(cred):
263 +    """Can be "true" """
264 +    if cred:
265 +        cred = cred.lower()
266 +    if cred and cred != 'true':
267 +        raise ValueError('Accept-Control-Allow-Credentials can only be "true" (%s)' % cred)
268 +    return cred
269 +
270  ##### Generation utilities
271  def quoteString(s):
272      return '"%s"' % s.replace('\\', '\\\\').replace('"', '\\"')
273 
274  def listGenerator(fun):
@@ -1444,10 +1482,16 @@
275  parser_request_headers = {
276      'Accept': (tokenize, listParser(parseAccept), dict),
277      'Accept-Charset': (tokenize, listParser(parseAcceptQvalue), dict, addDefaultCharset),
278      'Accept-Encoding':(tokenize, listParser(parseAcceptQvalue), dict, addDefaultEncoding),
279      'Accept-Language':(tokenize, listParser(parseAcceptQvalue), dict),
280 +    'Access-Control-Allow-Origin': (last, parseAllowOrigin,),
281 +    'Access-Control-Allow-Credentials': (last, parseAllowCreds,),
282 +    'Access-Control-Allow-Methods': (tokenize, listParser(parseHTTPMethod), list),
283 +    'Access-Control-Request-Method': (parseHTTPMethod, ),
284 +    'Access-Control-Request-Headers': (filterTokens, ),
285 +    'Access-Control-Expose-Headers': (filterTokens, ),
286      'Authorization': (last, parseAuthorization),
287      'Cookie':(parseCookie,),
288      'Expect':(tokenize, listParser(parseExpect), dict),
289      'From':(last,),
290      'Host':(last,),
@@ -1455,10 +1499,11 @@
291      'If-Modified-Since':(last, parseIfModifiedSince),
292      'If-None-Match':(tokenize, listParser(parseStarOrETag), list),
293      'If-Range':(parseIfRange,),
294      'If-Unmodified-Since':(last,parseDateTime),
295      'Max-Forwards':(last,int),
296 +    'Origin': (last,),
297  #    'Proxy-Authorization':str, # what is "credentials"
298      'Range':(tokenize, parseRange),
299      'Referer':(last,str), # TODO: URI object?
300      'TE':(tokenize, listParser(parseAcceptQvalue), dict),
301      'User-Agent':(last,str),
@@ -1467,15 +1512,19 @@
302  generator_request_headers = {
303      'Accept': (iteritems,listGenerator(generateAccept),singleHeader),
304      'Accept-Charset': (iteritems, listGenerator(generateAcceptQvalue),singleHeader),
305      'Accept-Encoding': (iteritems, removeDefaultEncoding, listGenerator(generateAcceptQvalue),singleHeader),
306      'Accept-Language': (iteritems, listGenerator(generateAcceptQvalue),singleHeader),
307 +    'Access-Control-Request-Method': (unique, str, singleHeader, ),
308 +    'Access-Control-Expose-Headers': (listGenerator(str), ),
309 +    'Access-Control-Allow-Headers': (listGenerator(str), ),
310      'Authorization': (generateAuthorization,), # what is "credentials"
311      'Cookie':(generateCookie,singleHeader),
312      'Expect':(iteritems, listGenerator(generateExpect), singleHeader),
313      'From':(str,singleHeader),
314      'Host':(str,singleHeader),
315 +    'Origin': (unique, str, singleHeader),
316      'If-Match':(listGenerator(generateStarOrETag), singleHeader),
317      'If-Modified-Since':(generateDateTime,singleHeader),
318      'If-None-Match':(listGenerator(generateStarOrETag), singleHeader),
319      'If-Range':(generateIfRange, singleHeader),
320      'If-Unmodified-Since':(generateDateTime,singleHeader),
diff --git a/web/test/unittest_http.py b/web/test/unittest_http.py
@@ -13,13 +13,17 @@
321  # FOR A PARTICULAR PURPOSE.  See the GNU Lesser General Public License for more
322  # details.
323  #
324  # You should have received a copy of the GNU Lesser General Public License along
325  # with CubicWeb.  If not, see <http://www.gnu.org/licenses/>.
326 +
327 +import contextlib
328 +
329  from logilab.common.testlib import TestCase, unittest_main, tag, Tags
330 
331  from cubicweb.devtools.fake import FakeRequest
332 +from cubicweb.devtools.testlib import CubicWebTC
333 
334 
335  def _test_cache(hin, hout, method='GET'):
336      """forge and process an HTTP request using given headers in/out and method,
337      then return it once its .is_client_cache_valid() method has been called.
@@ -288,7 +292,169 @@
338          self.assertCache(None, req.status_out, 'not modifier HEAD verb')
339          value = req.headers_out.getRawHeaders('expires')
340          self.assertEqual(value, [DATE])
341 
342 
343 +alloworig = 'access-control-allow-origin'
344 +allowmethods = 'access-control-allow-methods'
345 +allowheaders = 'access-control-allow-headers'
346 +allowcreds = 'access-control-allow-credentials'
347 +exposeheaders = 'access-control-expose-headers'
348 +maxage = 'access-control-max-age'
349 +
350 +requestmethod = 'access-control-request-method'
351 +requestheaders = 'access-control-request-headers'
352 +
353 +class _BaseAccessHeadersTC(CubicWebTC):
354 +
355 +    @contextlib.contextmanager
356 +    def options(self, **options):
357 +        for k, values in options.items():
358 +            self.config.set_option(k, values)
359 +        try:
360 +            yield
361 +        finally:
362 +            for k in options:
363 +                self.config.set_option(k, '')
364 +    def check_no_cors(self, req):
365 +        self.assertEqual(None, req.get_response_header(alloworig))
366 +        self.assertEqual(None, req.get_response_header(allowmethods))
367 +        self.assertEqual(None, req.get_response_header(allowheaders))
368 +        self.assertEqual(None, req.get_response_header(allowcreds))
369 +        self.assertEqual(None, req.get_response_header(exposeheaders))
370 +        self.assertEqual(None, req.get_response_header(maxage))
371 +
372 +
373 +class SimpleAccessHeadersTC(_BaseAccessHeadersTC):
374 +
375 +    def test_noaccess(self):
376 +        with self.admin_access.web_request() as req:
377 +            data = self.app_handle_request(req)
378 +            self.check_no_cors(req)
379 +
380 +    def test_noorigin(self):
381 +        with self.options(**{alloworig: '*'}):
382 +            with self.admin_access.web_request() as req:
383 +                req = self.request()
384 +                data = self.app_handle_request(req)
385 +                self.check_no_cors(req)
386 +
387 +    def test_origin_noaccess(self):
388 +        with self.admin_access.web_request() as req:
389 +            req.set_request_header('Origin', 'http://www.cubicweb.org')
390 +            data = self.app_handle_request(req)
391 +            self.check_no_cors(req)
392 +
393 +    def test_origin_noaccess_bad_host(self):
394 +        with self.options(**{alloworig: '*'}):
395 +            with self.admin_access.web_request() as req:
396 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
397 +                # in these tests, base_url is http://testing.fr/cubicweb/
398 +                req.set_request_header('Host', 'badhost.net')
399 +                data = self.app_handle_request(req)
400 +                self.check_no_cors(req)
401 +
402 +    def test_explicit_origin_noaccess(self):
403 +        with self.options(**{alloworig: ['http://www.toto.org', 'http://othersite.fr']}):
404 +            with self.admin_access.web_request() as req:
405 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
406 +                # in these tests, base_url is http://testing.fr/cubicweb/
407 +                req.set_request_header('Host', 'testing.fr')
408 +                data = self.app_handle_request(req)
409 +                self.check_no_cors(req)
410 +
411 +    def test_origin_access(self):
412 +        with self.options(**{alloworig: '*'}):
413 +            with self.admin_access.web_request() as req:
414 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
415 +                # in these tests, base_url is http://testing.fr/cubicweb/
416 +                req.set_request_header('Host', 'testing.fr')
417 +                data = self.app_handle_request(req)
418 +                self.assertEqual('http://www.cubicweb.org',
419 +                                 req.get_response_header(alloworig))
420 +
421 +    def test_explicit_origin_access(self):
422 +        with self.options(**{alloworig: ['http://www.cubicweb.org', 'http://othersite.fr']}):
423 +            with self.admin_access.web_request() as req:
424 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
425 +                # in these tests, base_url is http://testing.fr/cubicweb/
426 +                req.set_request_header('Host', 'testing.fr')
427 +                data = self.app_handle_request(req)
428 +                self.assertEqual('http://www.cubicweb.org',
429 +                                 req.get_response_header(alloworig))
430 +
431 +    def test_origin_access_headers(self):
432 +        with self.options(**{alloworig: '*',
433 +                             exposeheaders: ['ExposeHead1', 'ExposeHead2'],
434 +                             allowheaders: ['AllowHead1', 'AllowHead2'],
435 +                             allowmethods: ['GET', 'POST', 'OPTIONS']}):
436 +            with self.admin_access.web_request() as req:
437 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
438 +                # in these tests, base_url is http://testing.fr/cubicweb/
439 +                req.set_request_header('Host', 'testing.fr')
440 +                data = self.app_handle_request(req)
441 +                self.assertEqual('http://www.cubicweb.org',
442 +                                 req.get_response_header(alloworig))
443 +                self.assertEqual("true",
444 +                                 req.get_response_header(allowcreds))
445 +                self.assertEqual(['ExposeHead1', 'ExposeHead2'],
446 +                                 req.get_response_header(exposeheaders))
447 +                self.assertEqual(None, req.get_response_header(allowmethods))
448 +                self.assertEqual(None, req.get_response_header(allowheaders))
449 +
450 +
451 +class PreflightAccessHeadersTC(_BaseAccessHeadersTC):
452 +
453 +    def test_noaccess(self):
454 +        with self.admin_access.web_request(method='OPTIONS') as req:
455 +            data = self.app_handle_request(req)
456 +            self.check_no_cors(req)
457 +
458 +    def test_noorigin(self):
459 +        with self.options(**{alloworig: '*'}):
460 +            with self.admin_access.web_request(method='OPTIONS') as req:
461 +                req = self.request()
462 +                data = self.app_handle_request(req)
463 +                self.check_no_cors(req)
464 +
465 +    def test_origin_noaccess(self):
466 +        with self.admin_access.web_request(method='OPTIONS') as req:
467 +            req.set_request_header('Origin', 'http://www.cubicweb.org')
468 +            data = self.app_handle_request(req)
469 +            self.check_no_cors(req)
470 +
471 +    def test_origin_noaccess_bad_host(self):
472 +        with self.options(**{alloworig: '*'}):
473 +            with self.admin_access.web_request(method='OPTIONS') as req:
474 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
475 +                # in these tests, base_url is http://testing.fr/cubicweb/
476 +                req.set_request_header('Host', 'badhost.net')
477 +                data = self.app_handle_request(req)
478 +                self.check_no_cors(req)
479 +
480 +    def test_origin_access(self):
481 +        with self.options(**{alloworig: '*',
482 +                             exposeheaders: ['ExposeHead1', 'ExposeHead2'],
483 +                             allowheaders: ['AllowHead1', 'AllowHead2'],
484 +                             allowmethods: ['GET', 'POST', 'OPTIONS']}):
485 +            with self.admin_access.web_request(method='OPTIONS') as req:
486 +                req.set_request_header('Origin', 'http://www.cubicweb.org')
487 +                # in these tests, base_url is http://testing.fr/cubicweb/
488 +                req.set_request_header('Host', 'testing.fr')
489 +                req.set_request_header(requestmethod, 'GET')
490 +
491 +                data = self.app_handle_request(req)
492 +                self.assertEqual(200, req.status_out)
493 +                self.assertEqual('http://www.cubicweb.org',
494 +                                 req.get_response_header(alloworig))
495 +                self.assertEqual("true",
496 +                                 req.get_response_header(allowcreds))
497 +                self.assertEqual(set(['GET', 'POST', 'OPTIONS']),
498 +                                 req.get_response_header(allowmethods))
499 +                self.assertEqual(set(['AllowHead1', 'AllowHead2']),
500 +                                 req.get_response_header(allowheaders))
501 +                self.assertEqual(None,
502 +                                 req.get_response_header(exposeheaders))
503 +
504 +
505  if __name__ == '__main__':
506      unittest_main()
diff --git a/web/webconfig.py b/web/webconfig.py
@@ -245,10 +245,40 @@
507           {'type': 'string',
508            'default': None,
509            'help': 'The static data resource directory path.',
510            'group': 'web', 'level': 2,
511            }),
512 +        ('access-control-allow-origin',
513 +         {'type' : 'csv',
514 +          'default': (),
515 +          'help':('comma-separated list of allowed origin domains or "*" for any domain'),
516 +          'group': 'web', 'level': 2,
517 +          }),
518 +        ('access-control-allow-methods',
519 +         {'type' : 'csv',
520 +          'default': (),
521 +          'help': ('comma-separated list of allowed HTTP methods'),
522 +          'group': 'web', 'level': 2,
523 +          }),
524 +        ('access-control-max-age',
525 +         {'type' : 'int',
526 +          'default': None,
527 +          'help': ('maximum age of cross-origin resource sharing (in seconds)'),
528 +          'group': 'web', 'level': 2,
529 +          }),
530 +        ('access-control-expose-headers',
531 +         {'type' : 'csv',
532 +          'default': (),
533 +          'help':('comma-separated list of HTTP headers the application declare in response to a preflight request'),
534 +          'group': 'web', 'level': 2,
535 +          }),
536 +        ('access-control-allow-headers',
537 +         {'type' : 'csv',
538 +          'default': (),
539 +          'help':('comma-separated list of HTTP headers the application may set in the response'),
540 +          'group': 'web', 'level': 2,
541 +          }),
542          ))
543 
544      def __init__(self, *args, **kwargs):
545          super(WebConfiguration, self).__init__(*args, **kwargs)
546          self.uiprops = None