# 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.
# 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.
@@ -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
@@ -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 +
@@ -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
@@ -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 +
@@ -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),
@@ -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()
@@ -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