diff --git a/docs/api/api.rst b/docs/api/api.rst index c3b72ac..3b9a29f 100644 --- a/docs/api/api.rst +++ b/docs/api/api.rst @@ -18,4 +18,6 @@ standard-compliant WSGI server. .. autoclass:: falcon.RequestOptions :members: +.. autoclass:: falcon.ResponseOptions + :members: diff --git a/docs/api/cookies.rst b/docs/api/cookies.rst index 69c89d8..1c8e1de 100644 --- a/docs/api/cookies.rst +++ b/docs/api/cookies.rst @@ -98,14 +98,15 @@ the request. .. warning:: - For this attribute to be effective, your application will need to - enforce HTTPS when setting the cookie, as well as in all - subsequent requests that require the cookie to be sent back from - the client. + For this attribute to be effective, your web server or load + balancer will need to enforce HTTPS when setting the cookie, as + well as in all subsequent requests that require the cookie to be + sent back from the client. When running your application in a development environment, you can -disable this behavior by passing `secure=False` to -:py:meth:`~.Response.set_cookie`. This lets you test your app locally +disable this default behavior by setting +:py:attr:`~.ResponseOptions.secure_cookies_by_default` to ``False`` +via :any:`API.resp_options`. This lets you test your app locally without having to set up TLS. You can make this option configurable to easily switch between development and production environments. diff --git a/falcon/__init__.py b/falcon/__init__.py index 8c4f092..b9176bb 100644 --- a/falcon/__init__.py +++ b/falcon/__init__.py @@ -57,4 +57,4 @@ from falcon.util import * # NOQA from falcon.hooks import before, after # NOQA from falcon.request import Request, RequestOptions # NOQA -from falcon.response import Response # NOQA +from falcon.response import Response, ResponseOptions # NOQA diff --git a/falcon/api.py b/falcon/api.py index b8fe690..659eeec 100644 --- a/falcon/api.py +++ b/falcon/api.py @@ -23,7 +23,7 @@ from falcon.http_error import HTTPError from falcon.http_status import HTTPStatus from falcon.request import Request, RequestOptions import falcon.responders -from falcon.response import Response +from falcon.response import Response, ResponseOptions import falcon.status_codes as status from falcon.util.misc import get_argnames @@ -110,6 +110,8 @@ class API(object): Attributes: req_options: A set of behavioral options related to incoming requests. See also: :py:class:`~.RequestOptions` + resp_options: A set of behavioral options related to outgoing + responses. See also: :py:class:`~.ResponseOptions` """ # PERF(kgriffs): Reference via self since that is faster than @@ -125,7 +127,7 @@ class API(object): __slots__ = ('_request_type', '_response_type', '_error_handlers', '_media_type', '_router', '_sinks', - '_serialize_error', 'req_options', + '_serialize_error', 'req_options', 'resp_options', '_middleware', '_independent_middleware') def __init__(self, media_type=DEFAULT_MEDIA_TYPE, @@ -147,7 +149,9 @@ class API(object): self._error_handlers = [] self._serialize_error = helpers.default_serialize_error + self.req_options = RequestOptions() + self.resp_options = ResponseOptions() # NOTE(kgriffs): Add default error handlers self.add_error_handler(falcon.HTTPError, self._http_error_handler) @@ -170,7 +174,7 @@ class API(object): """ req = self._request_type(env, options=self.req_options) - resp = self._response_type() + resp = self._response_type(options=self.resp_options) resource = None params = {} diff --git a/falcon/request.py b/falcon/request.py index e909eb5..f36074b 100644 --- a/falcon/request.py +++ b/falcon/request.py @@ -286,12 +286,11 @@ class Request(object): string, the value mapped to that parameter key will be a list of all the values in the order seen. - options (dict): Set of global options passed from the API handler. - cookies (dict): A dict of name/value cookie pairs. See also: :ref:`Getting Cookies ` + options (dict): Set of global options passed from the API handler. """ __slots__ = ( diff --git a/falcon/response.py b/falcon/response.py index b9cf575..fda62c6 100644 --- a/falcon/response.py +++ b/falcon/response.py @@ -44,6 +44,9 @@ class Response(object): Note: `Response` is not meant to be instantiated directly by responders. + Keyword Arguments: + options (dict): Set of global options passed from the API handler. + Attributes: status (str): HTTP status line (e.g., '200 OK'). Falcon requires the full status line, not just the code (e.g., 200). This design @@ -109,6 +112,8 @@ class Response(object): opposed to a class), the function is called like a method of the current Response instance. Therefore the first argument is the Response instance itself (self). + + options (dict): Set of global options passed from the API handler. """ __slots__ = ( @@ -120,16 +125,19 @@ class Response(object): 'stream', 'stream_len', 'context', + 'options', '__dict__', ) # Child classes may override this context_type = None - def __init__(self): + def __init__(self, options=None): self.status = '200 OK' self._headers = {} + self.options = ResponseOptions() if options is None else options + # NOTE(tbug): will be set to a SimpleCookie object # when cookie is set via set_cookie self._cookies = None @@ -164,7 +172,7 @@ class Response(object): self.stream_len = stream_len def set_cookie(self, name, value, expires=None, max_age=None, - domain=None, path=None, secure=True, http_only=True): + domain=None, path=None, secure=None, http_only=True): """Set a response cookie. Note: @@ -223,6 +231,12 @@ class Response(object): (default: ``True``). This prevents attackers from reading sensitive cookie data. + Note: + The default value for this argument is normally + ``True``, but can be modified by setting + :py:attr:`~.ResponseOptions.secure_cookies_by_default` + via :any:`API.resp_options`. + Warning: For the `secure` cookie attribute to be effective, your application will need to enforce HTTPS. @@ -295,8 +309,13 @@ class Response(object): if path: self._cookies[name]['path'] = path - if secure: - self._cookies[name]['secure'] = secure + if secure is None: + is_secure = self.options.secure_cookies_by_default + else: + is_secure = secure + + if is_secure: + self._cookies[name]['secure'] = True if http_only: self._cookies[name]['httponly'] = http_only @@ -716,3 +735,24 @@ class Response(object): items += [('set-cookie', c.OutputString()) for c in self._cookies.values()] return items + + +class ResponseOptions(object): + """Defines a set of configurable response options. + + An instance of this class is exposed via :any:`API.resp_options` for + configuring certain :py:class:`~.Response` behaviors. + + Attributes: + secure_cookies_by_default (bool): Set to ``False`` in development + environments to make the `secure` attribute for all cookies + default to ``False``. This can make testing easier by + not requiring HTTPS. Note, however, that this setting can + be overridden via `set_cookie()`'s `secure` kwarg. + """ + __slots__ = ( + 'secure_cookies_by_default', + ) + + def __init__(self): + self.secure_cookies_by_default = True diff --git a/tests/test_cookies.py b/tests/test_cookies.py index d2ceb19..2122599 100644 --- a/tests/test_cookies.py +++ b/tests/test_cookies.py @@ -58,7 +58,7 @@ class CookieResourceMaxAgeFloatString: 'foostring', 'bar', max_age='15', secure=False, http_only=False) -@pytest.fixture(scope='module') +@pytest.fixture() def client(): app = falcon.API() app.add_route('/', CookieResource()) @@ -92,6 +92,18 @@ def test_response_base_case(client): assert cookie.secure +def test_response_disable_secure_globally(client): + client.app.resp_options.secure_cookies_by_default = False + result = client.simulate_get('/') + cookie = result.cookies['foo'] + assert not cookie.secure + + client.app.resp_options.secure_cookies_by_default = True + result = client.simulate_get('/') + cookie = result.cookies['foo'] + assert cookie.secure + + def test_response_complex_case(client): result = client.simulate_head('/')