Commits

Kirill Simonov  committed 3333468

tweak.csrf: protection against CSRF attacks.

  • Participants
  • Parent commits 630c141

Comments (0)

Files changed (14)

         'tweak = htsql.tweak:TweakAddon',
         'tweak.autolimit = htsql.tweak.autolimit:TweakAutolimitAddon',
         'tweak.cors = htsql.tweak.cors:TweakCORSAddon',
+        'tweak.csrf = htsql.tweak.csrf:TweakCSRFAddon',
         'tweak.hello = htsql.tweak.hello:TweakHelloAddon',
         'tweak.django = htsql.tweak.django:TweakDjangoAddon',
         'tweak.inet = htsql.tweak.inet:TweakINetAddon',

File src/htsql/core/application.py

     def __enter__(self):
         self.env.push(**self.updates)
 
-    def __exit__(self):
+    def __exit__(self, exc_type, exc_value, exc_traceback):
         self.env.pop()
 
 

File src/htsql/core/cmd/retrieve.py

 
     def __call__(self):
         binding = self.command.binding
-        if not context.env.can_read:
-            raise PermissionError("not enough permissions"
-                                  " to execute the query")
         expression = encode(binding)
         # FIXME: abstract it out.
         if isinstance(self.action, SafeProduceAction):
         meta = plan.profile.clone(plan=plan)
         data = None
         if plan.statement:
+            if not context.env.can_read:
+                raise PermissionError("not enough permissions"
+                                      " to execute the query")
             stream = None
             with transaction() as connection:
                 cursor = connection.cursor()

File src/htsql/ctl/regress.py

                       hint="""the HTTP status code to expect"""),
                 Field('ignore', BoolVal(), False,
                       hint="""ignore the response body"""),
+                Field('ignore_headers', BoolVal(), False,
+                      hint="""ignore the response headers"""),
         ] + SkipTestCase.Input.fields
 
         def init_attributes(self):
             return True
         if old_output.status != new_output.status:
             return True
-        if old_output.headers != new_output.headers:
-            return True
+        if not self.input.ignore_headers:
+            if old_output.headers != new_output.headers:
+                return True
         if not self.input.ignore:
             if old_output.body != new_output.body:
                 return True

File src/htsql/tweak/csrf/__init__.py

+#
+# Copyright (c) 2006-2012, Prometheus Research, LLC
+#
+
+
+from . import wsgi
+from ...core.addon import Addon, Parameter
+from ...core.validator import BoolVal
+
+
+class TweakCSRFAddon(Addon):
+
+    name = 'tweak.csrf'
+    hint = """Cross-Site Request Forgery (CSRF) protection"""
+    help = """
+    This addon provides protection against cross-site request
+    forgery (CSRF) attacks.
+
+    A CSRF attack tricks the user to visit the attacker's website,
+    which then submits database queries to the HTSQL service from
+    the user's account.  Even though the browser would not permit
+    the malicious website to read the output of the queries, this
+    form of attack can be used for denial of service or changing
+    the data in the database.  For background on CSRF, see
+    http://en.wikipedia.org/wiki/Cross-site_request_forgery.
+
+    This addon requires all HTSQL requests to submit a secret
+    token in two forms:
+
+    * as a cookie `htsql-csrf-token`;
+    * as HTTP header `X-HTSQL-CSRF-Token`.
+
+    If the token is not submitted, the addon prevents the request
+    from reading or updating any data in the database.
+
+    If the `allow_cs_read` parameter is set, a request is permitted
+    to read data from the database even when the secret token is
+    not provided.
+
+    If the `allow_cs_write` parameter is set, a request is permitted
+    to update data in the database even if the secret token is
+    not provided.
+    """
+
+    parameters = [
+            Parameter('allow_cs_read', BoolVal(), default=False,
+                      hint="""permit cross-site read requests"""),
+            Parameter('allow_cs_write', BoolVal(), default=False,
+                      hint="""permit cross-site write requests"""),
+    ]
+
+

File src/htsql/tweak/csrf/wsgi.py

+#
+# Copyright (c) 2006-2012, Prometheus Research, LLC
+#
+
+
+from ...core.context import context
+from ...core.adapter import rank
+from ...core.wsgi import WSGI
+from ...core.fmt.html import Template
+import Cookie
+import os
+import binascii
+
+
+class CSRFWSGI(WSGI):
+
+    rank(10.0)
+
+    csrf_secret_length = 16
+
+    def __call__(self):
+        token = None
+        if 'HTTP_COOKIE' in self.environ:
+            cookie = Cookie.SimpleCookie(self.environ['HTTP_COOKIE'])
+            if 'htsql-csrf-token' in cookie:
+                token = cookie['htsql-csrf-token'].value
+                secret = None
+                try:
+                    secret = binascii.a2b_hex(token)
+                except TypeError:
+                    pass
+                if secret is None or len(secret) != self.csrf_secret_length:
+                    token = None
+        header = self.environ.get('HTTP_X_HTSQL_CSRF_TOKEN')
+        if token and header and token == header:
+            for chunk in super(CSRFWSGI, self).__call__():
+                yield chunk
+            return
+        if not token:
+            token = binascii.b2a_hex(os.urandom(self.csrf_secret_length))
+        path = self.environ.get('SCRIPT_NAME', '')
+        if not path.endswith('/'):
+            path += '/'
+        morsel = Cookie.Morsel()
+        morsel.set('htsql-csrf-token', token, Cookie._quote(token))
+        morsel['path'] = path
+        cookie = morsel.OutputString()
+        # FIXME: avoid state changes in the adapter.
+        original_start_response = self.start_response
+        def start_response(status, headers, exc=None):
+            headers = headers+[('Set-Cookie', cookie)]
+            return original_start_response(status, headers, exc)
+        self.start_response = start_response
+        env = context.env
+        addon = context.app.tweak.csrf
+        can_read = env.can_read and addon.allow_cs_read
+        can_write = env.can_write and addon.allow_cs_write
+        with env(can_read=can_read, can_write=can_write):
+            for chunk in super(CSRFWSGI, self).__call__():
+                yield chunk
+
+

File src/htsql/tweak/meta/command.py

     adapt(MetaCmd, ProduceAction)
 
     def __call__(self):
+        can_read = context.env.can_read
+        can_write = context.env.can_write
         slave_app = get_slave_app()
         with slave_app:
-            binding = bind(self.command.syntax,
-                           environment=self.command.environment)
-            command = lookup_command(binding)
-            if command is None:
-                command = DefaultCmd(binding)
-            product = act(command, self.action)
+            with context.env(can_read=context.env.can_read and can_read,
+                             can_write=context.env.can_write and can_write):
+                binding = bind(self.command.syntax,
+                               environment=self.command.environment)
+                command = lookup_command(binding)
+                if command is None:
+                    command = DefaultCmd(binding)
+                product = act(command, self.action)
         return product
 
 

File src/htsql/tweak/resource/wsgi.py

         if method not in ['HEAD', 'GET']:
             self.start_response('400 Bad Request',
                                 [('Content-Type', 'text/plain')])
-            return ["Expected a %r request, got %r.\n" % method]
+            return ["Expected a GET request, got %r.\n" % method]
         path = path[len(indicator)+1:]
         resource = None
         if not (path.endswith('/') or '/.' in path or '\\' in path):

File test/input/addon.yaml

       tweak.cors: { origin: null }
   - uri: /school
 
+# TWEAK.CSRF - cross-site request forgery protection
+- title: tweak.csrf
+  tests:
+  # Addon description
+  - ctl: [ext, tweak.csrf]
+
+  # Test with both unprotected read and write disabled
+  - load: demo
+    extensions:
+      tweak.csrf: {}
+  - uri: /
+    ignore-headers: true
+  - uri: /school
+    ignore-headers: true
+    expect: 403
+
+  # Passing CSRF token back to the server
+  - py: pass-csrf-token
+    code: |
+      import wsgiref.util
+      class response:
+          status = None
+          headers = None
+          body = None
+      def start_response(status, headers, exc=None):
+          response.status = status
+          response.headers = headers
+      def request(environ):
+          wsgiref.util.setup_testing_defaults(environ)
+          response.status = None
+          response.headers = None
+          response.body = ''.join(state.app(environ, start_response))
+          return (response.status, response.headers, response.body)
+      status, headers, body = request({'PATH_INFO': "/"})
+      token = None
+      for header, value in headers:
+          if header.lower() == 'set-cookie':
+              if ';' in value:
+                  value = value.split(';', 1)[0]
+              if '=' in value:
+                  name, value = value.split('=', 1)
+                  if name == 'htsql-csrf-token':
+                      token = value
+      assert token is not None
+      environ = {
+          'PATH_INFO': "/school",
+          'HTTP_COOKIE': "htsql-csrf-token=%s" % token,
+          'HTTP_X_HTSQL_CSRF_TOKEN': token,
+      }
+      status, headers, body = request(environ)
+      assert status == "200 OK"
+      print status
+      for header, value in headers:
+          print "%s: %s" % (header, value)
+      print
+      print body
+
+  # Test with unprotected read enabled
+  - load: demo
+    extensions:
+      tweak.csrf:
+        allow_cs_read: true
+  - uri: /school
+    ignore-headers: true
+
 # TWEAK.DJANGO - adapt to Django
 - py: has-django
   ifdef: [sqlite, pgsql, mysql, oracle]
       tweak.meta: {}
   - uri: /meta(/column.sort(table.name, field.sort))
 
+  # Respect `can_read`
+  - load: demo
+    extensions:
+      tweak.csrf: {}
+      tweak.meta: {}
+  - uri: /meta(/table)
+    ignore-headers: true
+    expect: 403
+
 # TWEAK.OVERRIDE - adjust database metadata
 - title: tweak.override
   tests:

File test/output/mssql.yaml

                     [school].[campus]
              FROM [ad].[school]
              ORDER BY 1 ASC
+      - id: tweak.csrf
+        tests:
+        - ctl: [ext, tweak.csrf]
+          stdout: |+
+            TWEAK.CSRF - Cross-Site Request Forgery (CSRF) protection
+
+            This addon provides protection against cross-site request
+            forgery (CSRF) attacks.
+
+            A CSRF attack tricks the user to visit the attacker's website,
+            which then submits database queries to the HTSQL service from
+            the user's account.  Even though the browser would not permit
+            the malicious website to read the output of the queries, this
+            form of attack can be used for denial of service or changing
+            the data in the database.  For background on CSRF, see
+            http://en.wikipedia.org/wiki/Cross-site_request_forgery.
+
+            This addon requires all HTSQL requests to submit a secret
+            token in two forms:
+
+            * as a cookie `htsql-csrf-token`;
+            * as HTTP header `X-HTSQL-CSRF-Token`.
+
+            If the token is not submitted, the addon prevents the request
+            from reading or updating any data in the database.
+
+            If the `allow_cs_read` parameter is set, a request is permitted
+            to read data from the database even when the secret token is
+            not provided.
+
+            If the `allow_cs_write` parameter is set, a request is permitted
+            to update data in the database even if the secret token is
+            not provided.
+
+            Parameters:
+              allow-cs-read=ALLOW-CS-READ : permit cross-site read requests
+              allow-cs-write=ALLOW-CS-WRITE : permit cross-site write requests
+
+          exit: 0
+        - uri: /
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=d4b2f2a55c9cfdc6e798711a9b8eedb4; Path=/]
+          body: ''
+        - uri: /school
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=0aeb01ae13b9198e4c839b8d57dce551; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
+        - py: pass-csrf-token
+          stdout: |+
+            200 OK
+            Content-Type: text/plain; charset=UTF-8
+            Vary: Accept
+
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT [school].[code],
+                    [school].[name],
+                    [school].[campus]
+             FROM [ad].[school]
+             ORDER BY 1 ASC
+
+        - uri: /school
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=c47b6c770852ba941b3e875284948cba; Path=/]
+          body: |2
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT [school].[code],
+                    [school].[name],
+                    [school].[campus]
+             FROM [ad].[school]
+             ORDER BY 1 ASC
       - id: tweak.meta
         tests:
         - ctl: [ext, tweak.meta]
             FROM \"column\"\n      INNER JOIN \"field\"\n                 ON ((\"column\".\"table_name\"
             = \"field\".\"table_name\") AND (\"column\".\"name\" = \"field\".\"name\"))\n
             ORDER BY 1 ASC, \"field\".\"sort\" ASC, 2 ASC\n"
+        - uri: /meta(/table)
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=dda671e6af9a5e07b9b3ddcd3c0a98f3; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
       - id: tweak.override
         tests:
         - ctl: [ext, tweak.override]

File test/output/mysql.yaml

                     `school`.`campus`
              FROM `school`
              ORDER BY 1 ASC
+      - id: tweak.csrf
+        tests:
+        - ctl: [ext, tweak.csrf]
+          stdout: |+
+            TWEAK.CSRF - Cross-Site Request Forgery (CSRF) protection
+
+            This addon provides protection against cross-site request
+            forgery (CSRF) attacks.
+
+            A CSRF attack tricks the user to visit the attacker's website,
+            which then submits database queries to the HTSQL service from
+            the user's account.  Even though the browser would not permit
+            the malicious website to read the output of the queries, this
+            form of attack can be used for denial of service or changing
+            the data in the database.  For background on CSRF, see
+            http://en.wikipedia.org/wiki/Cross-site_request_forgery.
+
+            This addon requires all HTSQL requests to submit a secret
+            token in two forms:
+
+            * as a cookie `htsql-csrf-token`;
+            * as HTTP header `X-HTSQL-CSRF-Token`.
+
+            If the token is not submitted, the addon prevents the request
+            from reading or updating any data in the database.
+
+            If the `allow_cs_read` parameter is set, a request is permitted
+            to read data from the database even when the secret token is
+            not provided.
+
+            If the `allow_cs_write` parameter is set, a request is permitted
+            to update data in the database even if the secret token is
+            not provided.
+
+            Parameters:
+              allow-cs-read=ALLOW-CS-READ : permit cross-site read requests
+              allow-cs-write=ALLOW-CS-WRITE : permit cross-site write requests
+
+          exit: 0
+        - uri: /
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=7a0d9e90a6f0d007fefc9d5b5241a770; Path=/]
+          body: ''
+        - uri: /school
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=6e88eb02286204ff35023cf8f1dd8514; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
+        - py: pass-csrf-token
+          stdout: |+
+            200 OK
+            Content-Type: text/plain; charset=UTF-8
+            Vary: Accept
+
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT `school`.`code`,
+                    `school`.`name`,
+                    `school`.`campus`
+             FROM `school`
+             ORDER BY 1 ASC
+
+        - uri: /school
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=9e8c737db949ebfb91c9f8d2fd53dbd8; Path=/]
+          body: |2
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT `school`.`code`,
+                    `school`.`name`,
+                    `school`.`campus`
+             FROM `school`
+             ORDER BY 1 ASC
       - py: has-django
         stdout: ''
       - id: tweak.django
             FROM \"column\"\n      INNER JOIN \"field\"\n                 ON ((\"column\".\"table_name\"
             = \"field\".\"table_name\") AND (\"column\".\"name\" = \"field\".\"name\"))\n
             ORDER BY 1 ASC, \"field\".\"sort\" ASC, 2 ASC\n"
+        - uri: /meta(/table)
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=5dd2913f905c4f99e19d77dfdfcba8d9; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
       - id: tweak.override
         tests:
         - ctl: [ext, tweak.override]

File test/output/oracle.yaml

                     "SCHOOL"."CAMPUS"
              FROM "SCHOOL"
              ORDER BY 1 ASC
+      - id: tweak.csrf
+        tests:
+        - ctl: [ext, tweak.csrf]
+          stdout: |+
+            TWEAK.CSRF - Cross-Site Request Forgery (CSRF) protection
+
+            This addon provides protection against cross-site request
+            forgery (CSRF) attacks.
+
+            A CSRF attack tricks the user to visit the attacker's website,
+            which then submits database queries to the HTSQL service from
+            the user's account.  Even though the browser would not permit
+            the malicious website to read the output of the queries, this
+            form of attack can be used for denial of service or changing
+            the data in the database.  For background on CSRF, see
+            http://en.wikipedia.org/wiki/Cross-site_request_forgery.
+
+            This addon requires all HTSQL requests to submit a secret
+            token in two forms:
+
+            * as a cookie `htsql-csrf-token`;
+            * as HTTP header `X-HTSQL-CSRF-Token`.
+
+            If the token is not submitted, the addon prevents the request
+            from reading or updating any data in the database.
+
+            If the `allow_cs_read` parameter is set, a request is permitted
+            to read data from the database even when the secret token is
+            not provided.
+
+            If the `allow_cs_write` parameter is set, a request is permitted
+            to update data in the database even if the secret token is
+            not provided.
+
+            Parameters:
+              allow-cs-read=ALLOW-CS-READ : permit cross-site read requests
+              allow-cs-write=ALLOW-CS-WRITE : permit cross-site write requests
+
+          exit: 0
+        - uri: /
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=3a7567808e53e9904d3503978688e6ee; Path=/]
+          body: ''
+        - uri: /school
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=e313ff21de56e2db047db57971be4bfa; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
+        - py: pass-csrf-token
+          stdout: |+
+            200 OK
+            Content-Type: text/plain; charset=UTF-8
+            Vary: Accept
+
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT "SCHOOL"."CODE",
+                    "SCHOOL"."NAME",
+                    "SCHOOL"."CAMPUS"
+             FROM "SCHOOL"
+             ORDER BY 1 ASC
+
+        - uri: /school
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=10570c2615b031dff1089eb2ecf79609; Path=/]
+          body: |2
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT "SCHOOL"."CODE",
+                    "SCHOOL"."NAME",
+                    "SCHOOL"."CAMPUS"
+             FROM "SCHOOL"
+             ORDER BY 1 ASC
       - py: has-django
         stdout: ''
       - id: tweak.django
             FROM \"column\"\n      INNER JOIN \"field\"\n                 ON ((\"column\".\"table_name\"
             = \"field\".\"table_name\") AND (\"column\".\"name\" = \"field\".\"name\"))\n
             ORDER BY 1 ASC, \"field\".\"sort\" ASC, 2 ASC\n"
+        - uri: /meta(/table)
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=41fe4fd90b830c80f59887e554cf2e1a; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
       - id: tweak.override
         tests:
         - ctl: [ext, tweak.override]

File test/output/pgsql.yaml

                     "school"."campus"
              FROM "ad"."school"
              ORDER BY 1 ASC
+      - id: tweak.csrf
+        tests:
+        - ctl: [ext, tweak.csrf]
+          stdout: |+
+            TWEAK.CSRF - Cross-Site Request Forgery (CSRF) protection
+
+            This addon provides protection against cross-site request
+            forgery (CSRF) attacks.
+
+            A CSRF attack tricks the user to visit the attacker's website,
+            which then submits database queries to the HTSQL service from
+            the user's account.  Even though the browser would not permit
+            the malicious website to read the output of the queries, this
+            form of attack can be used for denial of service or changing
+            the data in the database.  For background on CSRF, see
+            http://en.wikipedia.org/wiki/Cross-site_request_forgery.
+
+            This addon requires all HTSQL requests to submit a secret
+            token in two forms:
+
+            * as a cookie `htsql-csrf-token`;
+            * as HTTP header `X-HTSQL-CSRF-Token`.
+
+            If the token is not submitted, the addon prevents the request
+            from reading or updating any data in the database.
+
+            If the `allow_cs_read` parameter is set, a request is permitted
+            to read data from the database even when the secret token is
+            not provided.
+
+            If the `allow_cs_write` parameter is set, a request is permitted
+            to update data in the database even if the secret token is
+            not provided.
+
+            Parameters:
+              allow-cs-read=ALLOW-CS-READ : permit cross-site read requests
+              allow-cs-write=ALLOW-CS-WRITE : permit cross-site write requests
+
+          exit: 0
+        - uri: /
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=be2732d93c37f15dc0597de1ee6125bf; Path=/]
+          body: ''
+        - uri: /school
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=f9f0726f2ce441b6dedcb86e73b02b04; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
+        - py: pass-csrf-token
+          stdout: |+
+            200 OK
+            Content-Type: text/plain; charset=UTF-8
+            Vary: Accept
+
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT "school"."code",
+                    "school"."name",
+                    "school"."campus"
+             FROM "ad"."school"
+             ORDER BY 1 ASC
+
+        - uri: /school
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=9a5f05259e12e7696beafd577e32b7be; Path=/]
+          body: |2
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT "school"."code",
+                    "school"."name",
+                    "school"."campus"
+             FROM "ad"."school"
+             ORDER BY 1 ASC
       - py: has-django
         stdout: ''
       - id: tweak.django
             FROM \"column\"\n      INNER JOIN \"field\"\n                 ON ((\"column\".\"table_name\"
             = \"field\".\"table_name\") AND (\"column\".\"name\" = \"field\".\"name\"))\n
             ORDER BY 1 ASC, \"field\".\"sort\" ASC, 2 ASC\n"
+        - uri: /meta(/table)
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=ab09178c376c4a22dddc17618850e520; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
       - id: tweak.override
         tests:
         - ctl: [ext, tweak.override]

File test/output/sqlite.yaml

                     "school"."campus"
              FROM "school"
              ORDER BY 1 ASC
+      - id: tweak.csrf
+        tests:
+        - ctl: [ext, tweak.csrf]
+          stdout: |+
+            TWEAK.CSRF - Cross-Site Request Forgery (CSRF) protection
+
+            This addon provides protection against cross-site request
+            forgery (CSRF) attacks.
+
+            A CSRF attack tricks the user to visit the attacker's website,
+            which then submits database queries to the HTSQL service from
+            the user's account.  Even though the browser would not permit
+            the malicious website to read the output of the queries, this
+            form of attack can be used for denial of service or changing
+            the data in the database.  For background on CSRF, see
+            http://en.wikipedia.org/wiki/Cross-site_request_forgery.
+
+            This addon requires all HTSQL requests to submit a secret
+            token in two forms:
+
+            * as a cookie `htsql-csrf-token`;
+            * as HTTP header `X-HTSQL-CSRF-Token`.
+
+            If the token is not submitted, the addon prevents the request
+            from reading or updating any data in the database.
+
+            If the `allow_cs_read` parameter is set, a request is permitted
+            to read data from the database even when the secret token is
+            not provided.
+
+            If the `allow_cs_write` parameter is set, a request is permitted
+            to update data in the database even if the secret token is
+            not provided.
+
+            Parameters:
+              allow-cs-read=ALLOW-CS-READ : permit cross-site read requests
+              allow-cs-write=ALLOW-CS-WRITE : permit cross-site write requests
+
+          exit: 0
+        - uri: /
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=9d0e1a3cc3281b0685939fa12e73a261; Path=/]
+          body: ''
+        - uri: /school
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=78bd4b1268a1e51c64f0fa6725568258; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
+        - py: pass-csrf-token
+          stdout: |+
+            200 OK
+            Content-Type: text/plain; charset=UTF-8
+            Vary: Accept
+
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT "school"."code",
+                    "school"."name",
+                    "school"."campus"
+             FROM "school"
+             ORDER BY 1 ASC
+
+        - uri: /school
+          status: 200 OK
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Vary, Accept]
+          - [Set-Cookie, htsql-csrf-token=6381d6445194aebcdccea0ceb49bac9f; Path=/]
+          body: |2
+             | school                                        |
+             +------+-------------------------------+--------+
+             | code | name                          | campus |
+            -+------+-------------------------------+--------+-
+             | art  | School of Art & Design        | old    |
+             | bus  | School of Business            | south  |
+             | edu  | College of Education          | old    |
+             | eng  | School of Engineering         | north  |
+             | la   | School of Arts and Humanities | old    |
+             | mus  | School of Music & Dance       | south  |
+             | ns   | School of Natural Sciences    | old    |
+             | ph   | Public Honorariums            |        |
+             | sc   | School of Continuing Studies  |        |
+
+             ----
+             /school
+             SELECT "school"."code",
+                    "school"."name",
+                    "school"."campus"
+             FROM "school"
+             ORDER BY 1 ASC
       - py: has-django
         stdout: ''
       - id: tweak.django
             FROM \"column\"\n      INNER JOIN \"field\"\n                 ON ((\"column\".\"table_name\"
             = \"field\".\"table_name\") AND (\"column\".\"name\" = \"field\".\"name\"))\n
             ORDER BY 1 ASC, \"field\".\"sort\" ASC, 2 ASC\n"
+        - uri: /meta(/table)
+          status: 403 Forbidden
+          headers:
+          - [Content-Type, text/plain; charset=UTF-8]
+          - [Set-Cookie, htsql-csrf-token=375668e883643a161ac26400204b6dba; Path=/]
+          body: |
+            not enough permissions: not enough permissions to execute the query
       - id: tweak.override
         tests:
         - ctl: [ext, tweak.override]