Ticket #20751: 0001-socketumask-argument-for-runfcgi-tests.patch

File 0001-socketumask-argument-for-runfcgi-tests.patch, 15.8 KB (added by ntrrgc@…, 11 years ago)

Patch

  • django/core/servers/fastcgi.py

    From 3667fc2b388853b90a5a6310cd07e96d14f580c4 Mon Sep 17 00:00:00 2001
    From: =?UTF-8?q?Juan=20Luis=20Boya=20Garc=C3=ADa?= <ntrrgc@gmail.com>
    Date: Sun, 14 Jul 2013 22:05:50 +0200
    Subject: [PATCH] socketumask argument for runfcgi, tests.
    
    A new option has been added to runfcgi in order to control socket
    permissions, necessary to securely deploy Django in a different system
    user than the web server's.
    
    Docs have been updated to refer to this new option instead of the old
    umask option which also did set the permissions for every file created
    by Django.
    
    Probably I have been wasting my time with this last one, but an
    (incomplete) test battery for runfcgi has been added.
    ---
     django/core/servers/fastcgi.py    |   9 +
     docs/howto/deployment/fastcgi.txt |  11 +-
     tests/fcgi/__init__.py            |   0
     tests/fcgi/manage.py              |   9 +
     tests/fcgi/settings.py            |   3 +
     tests/fcgi/tests.py               | 335 ++++++++++++++++++++++++++++++++++++++
     tests/fcgi/urls.py                |  19 +++
     7 files changed, 384 insertions(+), 2 deletions(-)
     create mode 100644 tests/fcgi/__init__.py
     create mode 100644 tests/fcgi/manage.py
     create mode 100644 tests/fcgi/settings.py
     create mode 100644 tests/fcgi/tests.py
     create mode 100644 tests/fcgi/urls.py
    
    diff --git a/django/core/servers/fastcgi.py b/django/core/servers/fastcgi.py
    index 2ae1fa5..db8a81b 100644
    a b FASTCGI_OPTIONS = {  
    3636    'outlog': None,
    3737    'errlog': None,
    3838    'umask': None,
     39    'socketumask': None,
    3940}
    4041
    4142FASTCGI_HELP = r"""
    Optional Fcgi settings: (setting=value)  
    6364  outlog=FILE          write stdout to this file.
    6465  errlog=FILE          write stderr to this file.
    6566  umask=UMASK          umask to use when daemonizing, in octal notation (default 022).
     67  socketumask=UMASK    umask to use when creating the socket, in octal notation
     68                       (default 022).
    6669
    6770Examples:
    6871  Run a "standard" fastcgi process on a file-descriptor
    def runfastcgi(argset=[], **kwargs):  
    163166            return fastcgi_help("ERROR: Invalid option for daemonize "
    164167                                "parameter.")
    165168
     169    if options['socketumask'] and not options['socket']:
     170        return fastcgi_help("ERROR: socketumask requires socket parameter")
     171
    166172    daemon_kwargs = {}
    167173    if options['outlog']:
    168174        daemon_kwargs['out_log'] = options['outlog']
    def runfastcgi(argset=[], **kwargs):  
    171177    if options['umask']:
    172178        daemon_kwargs['umask'] = int(options['umask'], 8)
    173179
     180    if options['socketumask']:
     181        wsgi_opts['umask'] = int(options['socketumask'], 8)
     182
    174183    if daemonize:
    175184        from django.utils.daemonize import become_daemon
    176185        become_daemon(our_home_dir=options["workdir"], **daemon_kwargs)
  • docs/howto/deployment/fastcgi.txt

    diff --git a/docs/howto/deployment/fastcgi.txt b/docs/howto/deployment/fastcgi.txt
    index 507e50d..3796be8 100644
    a b Running a preforked server on a Unix domain socket::  
    115115    Django's default umask requires that the web server and the Django fastcgi
    116116    process be run with the same group **and** user. For increased security,
    117117    you can run them under the same group but as different users. If you do
    118     this, you will need to set the umask to 0002 using the ``umask`` argument
    119     to ``runfcgi``.
     118    this, you will need to set the umask to 0007 using the ``socketumask``
     119    argument to ``runfcgi``.
     120
     121    .. versionchanged:: dev
     122
     123        Up to Django 1.6 there was only the ``umask`` argument which sets the
     124        umask for all files created by Django (not only the socket) and does
     125        not work with ``daemonize=false``.
     126
    120127
    121128Run without daemonizing (backgrounding) the process (good for debugging)::
    122129
  • new file tests/fcgi/manage.py

    diff --git a/tests/fcgi/__init__.py b/tests/fcgi/__init__.py
    new file mode 100644
    index 0000000..e69de29
    diff --git a/tests/fcgi/manage.py b/tests/fcgi/manage.py
    new file mode 100644
    index 0000000..8e3da96
    - +  
     1import os
     2import sys
     3
     4if __name__ == "__main__":
     5    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
     6
     7    from django.core.management import execute_from_command_line
     8
     9    execute_from_command_line(sys.argv)
  • new file tests/fcgi/settings.py

    diff --git a/tests/fcgi/settings.py b/tests/fcgi/settings.py
    new file mode 100644
    index 0000000..62f17c0
    - +  
     1ROOT_URLCONF = 'urls'
     2DEBUG = True
     3SECRET_KEY = 'nawrulez'
  • new file tests/fcgi/tests.py

    diff --git a/tests/fcgi/tests.py b/tests/fcgi/tests.py
    new file mode 100644
    index 0000000..307ed4f
    - +  
     1from __future__ import unicode_literals
     2
     3import unittest
     4import subprocess
     5import sys
     6import os
     7import time
     8import threading
     9import signal
     10import tempfile
     11from django.utils.six import StringIO
     12
     13try:
     14    import flup
     15    no_flup = False
     16except ImportError:
     17    no_flup = True
     18
     19"""
     20Waits for a process to terminate for a maximum of `timeout` seconds.
     21If the timeout is reached, the process is killed.
     22
     23Returns True if the process terminated within the timeout interval.
     24"""
     25def wait_or_kill(proc, timeout):
     26    def wait_thread():
     27        proc.wait()
     28
     29    thread = threading.Thread(target=wait_thread)
     30    thread.start()
     31
     32    thread.join(timeout)
     33    if thread.is_alive():
     34        proc.kill()
     35        thread.join()
     36        return False
     37
     38    return True
     39
     40
     41if os.name == 'posix':
     42    def pid_exists(pid):
     43        """Check whether pid exists in the current process table."""
     44        import errno
     45        # Adapted from http://stackoverflow.com/a/6940314/1777162
     46        if pid < 0:
     47            return False
     48        try:
     49            os.kill(pid, 0)
     50        except OSError as e:
     51            return e.errno == errno.EPERM
     52        else:
     53            return True
     54else:
     55    def pid_exists(pid):
     56        import ctypes
     57        kernel32 = ctypes.windll.kernel32
     58        SYNCHRONIZE = 0x100000
     59
     60        process = kernel32.OpenProcess(SYNCHRONIZE, 0, pid)
     61        if process != 0:
     62            kernel32.CloseHandle(process)
     63            return True
     64        else:
     65            return False
     66
     67
     68class BaseFastCGITest(object):
     69    def setUp(self):
     70        self.server_running = False
     71        self.host = None
     72        self.port = None
     73        self.socket = None
     74        self.args = []
     75        self.test_file = None
     76        self.pidfile = None
     77        self.umask = None
     78        self.socketumask = None
     79
     80    def assertFileMatchesUmask(self, filepath, umask):
     81        return self.assertEqual(
     82                    oct((os.stat(filepath).st_mode & 0o777) & ~0o111),
     83                    oct((0o777 - umask) & ~0o111))
     84
     85    def tearDown(self):
     86        if self.server_running:
     87            self.stop_fcgi_server()
     88
     89        if self.socket:
     90            os.unlink(self.socket)
     91
     92        if self.test_file:
     93            os.unlink(self.test_file)
     94
     95        if self.pidfile:
     96            os.unlink(self.pidfile)
     97
     98    def start_fcgi_server(self):
     99        python = sys.executable
     100        args = ['python', 'manage.py', 'runfcgi'] + self.args
     101        path = os.path.dirname(__file__)
     102
     103        repo = os.path.join(os.path.dirname(__file__), '../..')
     104
     105        self.proc = subprocess.Popen(args, executable=python, env=os.environ)
     106
     107    def sleep(self):
     108        # Sleep for half second
     109        time.sleep(0.5)
     110
     111    def talk_to_server(self, url):
     112        def set_status(self, got_status, got_headers):
     113            self['status'] = got_status
     114
     115        import flup.client.fcgi_app
     116        app = flup.client.fcgi_app.FCGIApp(connect=self.flup_connection_object)
     117
     118        env = {
     119           'SCRIPT_FILENAME': '/django.fcgi',
     120           'QUERY_STRING': '',
     121           'REQUEST_METHOD': 'GET',
     122           'SCRIPT_NAME': url,
     123           'REQUEST_URI': url,
     124           'GATEWAY_INTERFACE': 'CGI/1.1',
     125           'REDIRECT_STATUS': '200',
     126           'CONTENT_TYPE': '',
     127           'CONTENT_LENGTH': '0',
     128           'DOCUMENT_ROOT': '/',
     129           'DOCUMENT_ROOT': '/var/www/',
     130           'REMOTE_ADDR': '127.0.0.1',
     131           'SERVER_NAME': 'foo',
     132           'SERVER_PORT': '80',
     133           'SERVER_PROTOCOL': 'HTTP/1.1',
     134           'wsgi.input': StringIO('GET / HTTP/1.1\r\n\r\n'),
     135           'wsgi.errors': StringIO(),
     136        }
     137
     138        status_dict = {}
     139        response = app(env, set_status.__get__(status_dict))
     140
     141        return (status_dict['status'], ''.join(response))
     142
     143    def stop_fcgi_server(self):
     144        # Send SIGINT to process
     145        os.kill(self.pid, signal.SIGINT)
     146
     147        # Fail if not stopped within 1 second
     148        self.assertTrue(wait_or_kill(self.proc, 2), msg='The server did not stop')
     149
     150        self.server_running = False
     151
     152    @unittest.skipIf(no_flup, 'Flup is required to test runfcgi')
     153    def test_all(self):
     154        self.set_up_connection_params()
     155        self.set_up_daemonize()
     156        self.set_up_mp_mode()
     157        self.set_up_umask()
     158        self.set_up_socketumask()
     159
     160        self.start_fcgi_server()
     161        self.sleep()
     162        self.check_alive()
     163        self.check_socket_permissions() # if any
     164        self.do_test()
     165        self.stop_fcgi_server()
     166
     167    def check_socket_permissions(self):
     168        pass
     169
     170    def set_up_umask(self):
     171        pass
     172
     173    def set_up_socketumask(self):
     174        pass
     175
     176class TCPTest(object):
     177    def set_up_connection_params(self):
     178        self.port = 3333 # Bad luck if already used
     179        self.host = '127.0.0.1'
     180
     181        self.args += ['host=%s' % self.host,
     182                      'port=%d' % self.port ]
     183
     184    @property
     185    def flup_connection_object(self):
     186        return (self.host, self.port)
     187
     188
     189class UNIXSocketTest(object):
     190    def set_up_connection_params(self):
     191        self.socket = '/tmp/django-test-case-socket.sock'
     192
     193        self.args += ['socket=%s' % self.socket]
     194
     195    @property
     196    def flup_connection_object(self):
     197        return str(self.socket)
     198
     199
     200class NotDaemonizeTest(object):
     201    def set_up_daemonize(self):
     202        self.args += ['daemonize=false']
     203
     204    def check_alive(self):
     205        self.pid = self.proc.pid
     206
     207        # Process has not terminated (i.e. by error)
     208        self.assertIsNone(self.proc.poll(), msg='The server was prematurely shut down')
     209
     210        self.server_running = True
     211
     212
     213class DaemonizeTest(object):
     214    def set_up_daemonize(self):
     215        self.pidfile = os.path.join(tempfile.gettempdir(),
     216                                    'django-test-case-socket.pid')
     217
     218        self.args += ['daemonize=true',
     219                      'pidfile=%s' % self.pidfile]
     220
     221    def check_alive(self):
     222        with open(self.pidfile, 'r') as f:
     223            self.pid = int(f.read())
     224
     225        # Check there is a process with such PID
     226        self.assertTrue(pid_exists(self.pid))
     227
     228        self.server_running = True
     229
     230
     231class UmaskTest(object):
     232    def set_up_umask(self):
     233        self.umask = 0o002
     234
     235        self.args += ['umask=%#03o' % self.umask]
     236
     237
     238class SocketumaskTest(object):
     239    def set_up_socketumask(self):
     240        self.socketumask = 0o007
     241
     242        self.args += ['socketumask=%#03o' % self.socketumask]
     243
     244    def check_socket_permissions(self):
     245        self.assertFileMatchesUmask(self.socket, self.socketumask)
     246
     247class HelloWorldTest(object):
     248    def do_test(self):
     249        status, response = self.talk_to_server('/')
     250
     251        self.assertEqual(status, '200 OK')
     252        self.assertEqual(response, 'Hello World')
     253
     254
     255class TouchFileTest(object):
     256    def do_test(self):
     257        self.test_file = os.path.join(tempfile.gettempdir(), 'django-test-touch-file')
     258        status, response = self.talk_to_server('/touch_tmp/')
     259
     260        self.assertEqual(status, '200 OK')
     261        self.assertEqual(response, 'File created')
     262        self.assertTrue(os.path.exists(self.test_file))
     263
     264        if self.umask:
     265            self.assertFileMatchesUmask(self.test_file, self.umask)
     266
     267class ThreadedTest(object):
     268    def set_up_mp_mode(self):
     269        self.args += ['method=threaded']
     270
     271
     272class PreforkTest(object):
     273    def set_up_mp_mode(self):
     274        self.args += ['method=prefork']
     275
     276
     277tests = """
     278 ----------------------------------------------------------------------------------------------------------
     279|         ||     ||  Transport || Daemonize ||    Umask   || SocketUmask ||    Method     ||     Test      |
     280| Test No || Win ||------------||-----------||------------||-------------||---------------||---------------|
     281|         ||     || TCP | UNIX ||  F  |  T  || No | Umask || No | SUmask || Fork | Thread || Hello | Touch |
     282|----------------------------------------------------------------------------------------------------------|
     283|    1    ||  *  ||  *  |      ||  *  |     || *  |       || *  |        ||      |   *    ||   *   |       |
     284|    2    ||  *  ||  *  |      ||     |  *  || *  |       || *  |        ||      |   *    ||   *   |       |
     285|    3    ||     ||  *  |      ||     |  *  ||    |   *   || *  |        ||  *   |        ||       |   *   |
     286|    4    ||  *  ||  *  |      ||  *  |     || *  |       || *  |        ||      |   *    ||   *   |       |
     287|    5    ||  *  ||  *  |      ||     |  *  || *  |       || *  |        ||      |   *    ||   *   |       |
     288|    6    ||     ||     |  *   ||  *  |     || *  |       || *  |        ||  *   |        ||   *   |       |
     289|    7    ||     ||     |  *   ||  *  |     || *  |       ||    |    *   ||  *   |        ||   *   |       |
     290|    8    ||     ||     |  *   ||     |  *  ||    |   *   ||    |    *   ||  *   |        ||       |   *   |
     291 ----------------------------------------------------------------------------------------------------------
     292"""
     293
     294def create_test_cases(env, tests_str):
     295    def marked_subcol(col):
     296        ticks = [nsubcol
     297                 for (nsubcol, subcol)
     298                 in enumerate(col.split('|'))
     299                 if '*' in subcol]
     300        if len(ticks) == 1:
     301            return ticks[0]
     302        else:
     303            raise RuntimeError('Wrong number of marked columns')
     304
     305    rows = tests_str.split('\n')[6:-2] # Remove header and footer
     306    for row in rows:
     307        big_cols = row.split('||')
     308        test_no = int(
     309                big_cols[0][1:] # Remove leading |
     310                )
     311        class_name = str('Test%d' % test_no)
     312        parents = tuple(x for x in [
     313                (TCPTest          , UNIXSocketTest ) [marked_subcol(big_cols[2])],
     314                (NotDaemonizeTest , DaemonizeTest  ) [marked_subcol(big_cols[3])],
     315                (None             , UmaskTest      ) [marked_subcol(big_cols[4])],
     316                (None             , SocketumaskTest) [marked_subcol(big_cols[5])],
     317                (PreforkTest      , ThreadedTest   ) [marked_subcol(big_cols[6])],
     318                (HelloWorldTest   , TouchFileTest  ) [marked_subcol(big_cols[7])],
     319                BaseFastCGITest,
     320                unittest.TestCase,
     321                ] if x is not None)
     322
     323        cls = type(class_name, parents, {})
     324
     325        no_win = '*' not in big_cols[1]
     326        if no_win:
     327            cls.test_all = unittest.skipIf(sys.platform.startswith('win'),
     328                    'Windows does not support UNIX domain sockets nor fork')(cls.test_all)
     329
     330        env[class_name] = cls
     331
     332create_test_cases(locals(), tests)
     333
     334if __name__=="__main__":
     335    unittest.main()
  • new file tests/fcgi/urls.py

    diff --git a/tests/fcgi/urls.py b/tests/fcgi/urls.py
    new file mode 100644
    index 0000000..9f8b1b9
    - +  
     1from django.conf.urls import patterns, url
     2from django.http import HttpResponse
     3import tempfile
     4import os.path
     5
     6def hello(request):
     7    return HttpResponse('Hello World')
     8
     9def touch_tmp(request):
     10    path = os.path.join(tempfile.gettempdir(), 'django-test-touch-file')
     11    with open(path, 'w'):
     12        pass
     13
     14    return HttpResponse('File created')
     15
     16urlpatterns = patterns('',
     17    url(r'^$', hello),
     18    url(r'^touch_tmp/', touch_tmp),
     19)
Back to Top