source: mod_gnutls/test/mgstest/tests.py @ 1c76ea7

proxy-ticket
Last change on this file since 1c76ea7 was 1c76ea7, checked in by Fiona Klute <fiona.klute@…>, 10 months ago

mgstest.tests: Encode request body as utf-8

HTTPConnection.request() defaults to latin-1 for texts, which leads to
trouble if someone writes odd characters into a test definition. ;-)

  • Property mode set to 100644
File size: 19.5 KB
Line 
1#!/usr/bin/python3
2
3# Copyright 2019-2020 Fiona Klute
4#
5# Licensed under the Apache License, Version 2.0 (the "License");
6# you may not use this file except in compliance with the License.
7# You may obtain a copy of the License at
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS,
13# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14# See the License for the specific language governing permissions and
15# limitations under the License.
16
17"""Test objects and support functions for the mod_gnutls test
18suite. The classes defined in this module represent structures in the
19YAML test configuration files.
20
21The test configuration file defines either a TestConnection, or a list
22of them. Each connection contains a list of actions to run using this
23connection. The actions define their expected results, if an
24expectation is not met mgstest.TestExpectationFailed is raised.
25
26Example of a connection that runs two request actions, which are
27expected to succeed:
28
29```yaml
30!connection
31# "host" defaults to $TEST_TARGET, so usually there's no need to set
32# it. You can use ${VAR} to substitute environment variables.
33host: 'localhost'
34# "port" defaults to $TEST_PORT, so usually there's no need to set it
35# it. You can use ${VAR} to substitute environment variables.
36port: '${TEST_PORT}'
37# All elements of gnutls_params will be prefixed with "--" and passed
38# to gnutls-cli on the command line.
39gnutls_params:
40  - x509cafile=authority/x509.pem
41# The transport encryption. "Gnutls" is the default, "plain" can be
42# set to get an unencrypted connection (e.g. to test redirection to
43# HTTPS).
44transport: 'gnutls'
45description: 'This connection description will be logged.'
46actions:
47  - !request
48    # GET is the default.
49    method: GET
50    # The path part of the URL, required.
51    path: /test.txt
52    # "Expect" defines how the response must look to pass the test.
53    expect:
54      # 200 (OK) is the default.
55      status: 200
56      # The response body is analyzed only if the "body" element
57      # exists, otherwise any content is accepted.
58      body:
59        # The full response body must exactly match this string.
60        exactly: |
61          test
62  - !request
63    path: /status?auto
64    expect:
65      # The headers are analyzed only if the "headers" element exists.
66      headers:
67        # The Content-Type header must be present with exactly this
68        # value. You can use ${VAR} to substitute environment
69        # variables in the value.
70        Content-Type: 'text/plain; charset=ISO-8859-1'
71        # You can check the absence of a header by expecting null:
72        X-Forbidden-Header: null
73      body:
74        # All strings in this list must occur in the body, in any
75        # order. "Contains" may also contain a single string instead
76        # of a list.
77        contains:
78          - 'Using GnuTLS version: '
79          - 'Current TLS session: (TLS1.3)'
80```
81
82Example of a connection that is expected to fail at the TLS level, in
83this case because the configured CA is not the one that issued the
84server certificate:
85
86```yaml
87- !connection
88  gnutls_params:
89    - x509cafile=rogueca/x509.pem
90  actions:
91    - !request
92      path: /
93      expect:
94        # The connection is expected to reset without an HTTP response.
95        reset: yes
96```
97
98"""
99
100import os
101import re
102import select
103import subprocess
104import sys
105import yaml
106
107from enum import Enum, auto
108from http.client import HTTPConnection
109from string import Template
110
111from . import TestExpectationFailed
112from .http import HTTPSubprocessConnection
113
114class Transports(Enum):
115    """Transports supported by TestConnection."""
116    GNUTLS = auto()
117    PLAIN = auto()
118
119    def __repr__(self):
120        return f'{self.__class__.__name__!s}.{self.name}'
121
122class TestConnection(yaml.YAMLObject):
123    """An HTTP connection in a test. It includes parameters for the
124    transport, and the actions (e.g. sending requests) to take using
125    this connection.
126
127    Note that running one TestConnection object may result in multiple
128    sequential network connections if the transport gets closed in a
129    non-failure way (e.g. following a "Connection: close" request) and
130    there are more actions, or (rarely) if an action requires its own
131    transport.
132
133    """
134    yaml_tag = '!connection'
135
136    def __init__(self, actions, host=None, port=None, gnutls_params=[],
137                 transport='gnutls', description=None):
138        self.gnutls_params = gnutls_params
139        self.actions = actions
140        self.transport = Transports[transport.upper()]
141        self.description = description
142        if host:
143            self.host = subst_env(host)
144        else:
145            self.host = os.environ.get('TEST_TARGET', 'localhost')
146        if port:
147            self.port = int(subst_env(port))
148        else:
149            self.port = int(os.environ.get('TEST_PORT', 8000))
150
151    def __repr__(self):
152        return (f'{self.__class__.__name__!s}'
153                f'(host={self.host!r}, port={self.port!r}, '
154                f'gnutls_params={self.gnutls_params!r}, '
155                f'actions={self.actions!r}, transport={self.transport!r}, '
156                f'description={self.description!r})')
157
158    def run(self, timeout=5.0, conn_log=None, response_log=None):
159        """Set up an HTTP connection and run the configured actions."""
160
161        # note: "--logfile" option requires GnuTLS version >= 3.6.7
162        command = ['gnutls-cli', '--logfile=/dev/stderr']
163        for s in self.gnutls_params:
164            command.append('--' + s)
165        command = command + ['-p', str(self.port), self.host]
166
167        if self.transport == Transports.GNUTLS:
168            conn = HTTPSubprocessConnection(command, self.host, self.port,
169                                            output_filter=filter_cert_log,
170                                            stderr_log=conn_log,
171                                            timeout=timeout)
172        elif self.transport == Transports.PLAIN:
173            conn = HTTPConnection(self.host, port=self.port,
174                                  timeout=timeout)
175
176        try:
177            for act in self.actions:
178                if type(act) is TestRequest:
179                    act.run(conn, response_log)
180                elif type(act) is TestReq10:
181                    act.run(command, timeout, conn_log, response_log)
182                else:
183                    raise TypeError(f'Unsupported action requested: {act!r}')
184        finally:
185            conn.close()
186            sys.stdout.flush()
187
188    @classmethod
189    def from_yaml(cls, loader, node):
190        fields = loader.construct_mapping(node)
191        conn = cls(**fields)
192        return conn
193
194
195
196class TestRequest(yaml.YAMLObject):
197    """Test action that sends an HTTP/1.1 request.
198
199    The path must be specified in the configuration file, all other
200    parameters (method, headers, expected response) have
201    defaults.
202
203    Options for checking the response currently are:
204    * require a specific response status
205    * require specific headers to be present with specific values
206    * require the body to exactly match a specific string
207    * require the body to contain all of a list of strings
208
209    """
210    yaml_tag = '!request'
211    def __init__(self, path, method='GET', body=None, headers=dict(),
212                 expect=dict(status=200)):
213        self.method = method
214        self.path = path
215        self.body = body.encode('utf-8') if body else None
216        self.headers = headers
217        self.expect = expect
218
219    def __repr__(self):
220        return (f'{self.__class__.__name__!s}(path={self.path!r}, '
221                f'method={self.method!r}, body={self.body!r}, '
222                f'headers={self.headers!r}, expect={self.expect!r})')
223
224    def run(self, conn, response_log=None):
225        try:
226            conn.request(self.method, self.path, body=self.body,
227                         headers=self.headers)
228            resp = conn.getresponse()
229            if self.expects_conn_reset():
230                raise TestExpectationFailed(
231                    'Expected connection reset did not occur!')
232        except (BrokenPipeError, ConnectionResetError) as err:
233            if self.expects_conn_reset():
234                print('connection reset as expected.')
235                return
236            else:
237                raise err
238        body = resp.read().decode()
239        log_str = format_response(resp, body)
240        print(log_str)
241        if response_log:
242            print(log_str, file=response_log)
243        self.check_response(resp, body)
244
245    def check_headers(self, headers):
246        """
247        >>> r1 = TestRequest(path='/test.txt',
248        ...                  expect={ 'headers': {'X-Forbidden-Header': None,
249        ...                                       'X-Required-Header': 'Hi!' }})
250        >>> r1.check_headers({ 'X-Required-Header': 'Hi!' })
251        >>> r1.check_headers({ 'X-Required-Header': 'Hello!' })
252        Traceback (most recent call last):
253        ...
254        mgstest.TestExpectationFailed: Unexpected value in header X-Required-Header: 'Hello!', expected 'Hi!'
255        >>> r1.check_headers({ 'X-Forbidden-Header': 'Hi!' })
256        Traceback (most recent call last):
257        ...
258        mgstest.TestExpectationFailed: Unexpected value in header X-Forbidden-Header: 'Hi!', expected None
259        """
260        for name, expected in self.expect['headers'].items():
261            value = headers.get(name)
262            expected = subst_env(expected)
263            if value != expected:
264                raise TestExpectationFailed(
265                    f'Unexpected value in header {name}: {value!r}, '
266                    f'expected {expected!r}')
267
268    def check_body(self, body):
269        """
270        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'exactly': 'test\\n'}})
271        >>> r1.check_body('test\\n')
272        >>> r1.check_body('xyz\\n')
273        Traceback (most recent call last):
274        ...
275        mgstest.TestExpectationFailed: Unexpected body: 'xyz\\n' != 'test\\n'
276        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': ['tes', 'est']}})
277        >>> r2.check_body('test\\n')
278        >>> r2.check_body('est\\n')
279        Traceback (most recent call last):
280        ...
281        mgstest.TestExpectationFailed: Unexpected body: 'est\\n' does not contain 'tes'
282        >>> r3 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
283        >>> r3.check_body('test\\n')
284        """
285        if 'exactly' in self.expect['body'] \
286           and body != self.expect['body']['exactly']:
287            raise TestExpectationFailed(
288                f'Unexpected body: {body!r} != '
289                f'{self.expect["body"]["exactly"]!r}')
290        if 'contains' in self.expect['body']:
291            if type(self.expect['body']['contains']) is str:
292                self.expect['body']['contains'] = [
293                    self.expect['body']['contains']]
294            for s in self.expect['body']['contains']:
295                if not s in body:
296                    raise TestExpectationFailed(
297                        f'Unexpected body: {body!r} does not contain '
298                        f'{s!r}')
299
300    def check_response(self, response, body):
301        if self.expects_conn_reset():
302            raise TestExpectationFailed(
303                'Got a response, but connection should have failed!')
304        if response.status != self.expect['status']:
305            raise TestExpectationFailed(
306                f'Unexpected status: {response.status} != '
307                f'{self.expect["status"]}')
308        if 'headers' in self.expect:
309            self.check_headers(dict(response.getheaders()))
310        if 'body' in self.expect:
311            self.check_body(body)
312
313    def expects_conn_reset(self):
314        """Returns True if running this request is expected to fail due to the
315        connection being reset. That usually means the underlying TLS
316        connection failed.
317
318        >>> r1 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'status': 200, 'body': {'contains': 'test'}})
319        >>> r1.expects_conn_reset()
320        False
321        >>> r2 = TestRequest(path='/test.txt', method='GET', headers={}, expect={'reset': True})
322        >>> r2.expects_conn_reset()
323        True
324        """
325        if 'reset' in self.expect:
326            return self.expect['reset']
327        return False
328
329    @classmethod
330    def from_yaml(cls, loader, node):
331        fields = loader.construct_mapping(node)
332        req = cls(**fields)
333        return req
334
335
336
337class TestReq10(TestRequest):
338    """Test action that sends a request using a minimal (and likely
339    incomplete) HTTP/1.0 test client for the one test case that
340    strictly requires HTTP/1.0.
341
342    TestReq10 objects use the same YAML parameters and defaults as
343    TestRequest, but note that an empty "headers" parameter means that
344    not even a "Host:" header will be sent. All headers must be
345    specified in the test configuration file.
346
347    """
348    yaml_tag = '!request10'
349    status_re = re.compile(r'^HTTP/([\d\.]+) (\d+) (.*)$')
350    header_re = re.compile(r'^([-\w]+):\s+(.*)$')
351
352    def __init__(self, **kwargs):
353        super().__init__(**kwargs)
354
355    def run(self, command, timeout=None, conn_log=None, response_log=None):
356        req = f'{self.method} {self.path} HTTP/1.0\r\n'
357        for name, value in self.headers.items():
358            req = req + f'{name}: {value}\r\n'
359        req = req.encode('utf-8') + b'\r\n'
360        if self.body:
361            req = req + self.body
362        proc = subprocess.Popen(command,
363                                stdout=subprocess.PIPE,
364                                stderr=subprocess.PIPE,
365                                stdin=subprocess.PIPE,
366                                close_fds=True,
367                                bufsize=0)
368        try:
369            outs, errs = proc.communicate(input=req, timeout=timeout)
370        except TimeoutExpired:
371            proc.kill()
372            outs, errs = proc.communicate()
373
374        print(errs.decode())
375        if conn_log:
376            print(errs.decode(), file=conn_log)
377
378        if proc.returncode != 0:
379            if len(outs) != 0:
380                raise TestExpectationFailed(
381                    f'Connection failed, but got output: {outs!r}')
382            if self.expects_conn_reset():
383                print('connection reset as expected.')
384                return
385            else:
386                raise TestExpectationFailed(
387                    'Connection failed unexpectedly!')
388        else:
389            if self.expects_conn_reset():
390                raise TestExpectationFailed(
391                    'Expected connection reset did not occur!')
392
393        # first line of the received data must be the status
394        status, rest = outs.decode().split('\r\n', maxsplit=1)
395        # headers and body are separated by double newline
396        head, body = rest.split('\r\n\r\n', maxsplit=1)
397        # log response for debugging
398        print(f'{status}\n{head}\n\n{body}')
399        if response_log:
400            print(f'{status}\n{head}\n\n{body}', file=response_log)
401
402        m = self.status_re.match(status)
403        if m:
404            status_code = int(m.group(2))
405            status_expect = self.expect.get('status')
406            if status_expect and not status_code == status_expect:
407                raise TestExpectationFailed('Unexpected status code: '
408                                            f'{status}, expected '
409                                            f'{status_expect}')
410        else:
411            raise TestExpectationFailed(f'Invalid status line: "{status}"')
412
413        if 'headers' in self.expect:
414            headers = dict()
415            for line in head.splitlines():
416                m = self.header_re.fullmatch(line)
417                if m:
418                    headers[m.group(1)] = m.group(2)
419            self.check_headers(headers)
420
421        if 'body' in self.expect:
422            self.check_body(body)
423
424
425
426def filter_cert_log(in_stream, out_stream):
427    """Filter to stop an erroneous gnutls-cli log message.
428
429    This function filters out a log line about loading client
430    certificates that is mistakenly sent to stdout from gnutls-cli. My
431    fix (https://gitlab.com/gnutls/gnutls/merge_requests/1125) has
432    been merged, but buggy binaries will probably be around for a
433    while.
434
435    The filter is meant to run in a multiprocessing.Process or
436    threading.Thread that receives the stdout of gnutls-cli as
437    in_stream, and a connection for further processing as out_stream.
438
439    """
440    # message to filter
441    cert_log = b'Processed 1 client X.509 certificates...\n'
442
443    # Set the input to non-blocking mode
444    fd = in_stream.fileno()
445    os.set_blocking(fd, False)
446
447    # The poll object allows waiting for events on non-blocking IO
448    # channels.
449    poller = select.poll()
450    poller.register(fd)
451
452    init_done = False
453    run_loop = True
454    while run_loop:
455        # The returned tuples are file descriptor and event, but
456        # we're only listening on one stream anyway, so we don't
457        # need to check it here.
458        for x, event in poller.poll():
459            # Critical: "event" is a bitwise OR of the POLL* constants
460            if event & select.POLLIN or event & select.POLLPRI:
461                data = in_stream.read()
462                if not init_done:
463                    # If the erroneous log line shows up it's the
464                    # first piece of data we receive. Just copy
465                    # everything after.
466                    init_done = True
467                    if cert_log in data:
468                        data = data.replace(cert_log, b'')
469                out_stream.send(data)
470            if event & select.POLLHUP or event & select.POLLRDHUP:
471                # Stop the loop, but process any other events that
472                # might be in the list returned by poll() first.
473                run_loop = False
474
475    in_stream.close()
476    out_stream.close()
477
478
479
480def format_response(resp, body):
481    """Format an http.client.HTTPResponse for logging."""
482    s = f'{resp.status} {resp.reason}\n'
483    s = s + '\n'.join(f'{name}: {value}' for name, value in resp.getheaders())
484    s = s + '\n\n' + body
485    return s
486
487
488
489def subst_env(text):
490    """Use the parameter "text" as a template, substitute with environment
491    variables.
492
493    >>> os.environ['EXAMPLE_VAR'] = 'abc'
494    >>> subst_env('${EXAMPLE_VAR}def')
495    'abcdef'
496
497    Referencing undefined environment variables causes a KeyError.
498
499    >>> subst_env('${EXAMPLE_UNSET}')
500    Traceback (most recent call last):
501    ...
502    KeyError: 'EXAMPLE_UNSET'
503
504    >>> subst_env(None) is None
505    True
506
507    """
508    if not text:
509        return None
510    t = Template(text)
511    return t.substitute(os.environ)
512
513
514
515def run_test_conf(test_config, timeout=5.0, conn_log=None, response_log=None):
516    """Load and run a test configuration.
517
518    The test_conf parameter must be a YAML file, defining one or more
519    TestConnections, to be run in order. The other three parameters
520    are forwarded to TestConnection.run().
521
522    """
523    conns = None
524
525    config = yaml.load(test_config, Loader=yaml.Loader)
526    if type(config) is TestConnection:
527        conns = [config]
528    elif type(config) is list:
529        # assume list elements are connections
530        conns = config
531    else:
532        raise TypeError(f'Unsupported configuration: {config!r}')
533    print(conns)
534    sys.stdout.flush()
535
536    for i, test_conn in enumerate(conns):
537        if test_conn.description:
538            print(f'Running test connection {i}: {test_conn.description}')
539        else:
540            print(f'Running test connection {i}.')
541        sys.stdout.flush()
542        test_conn.run(timeout=timeout, conn_log=conn_log,
543                      response_log=response_log)
Note: See TracBrowser for help on using the repository browser.