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/django/core/servers/fastcgi.py
+++ b/django/core/servers/fastcgi.py
@@ -36,6 +36,7 @@ FASTCGI_OPTIONS = {
     'outlog': None,
     'errlog': None,
     'umask': None,
+    'socketumask': None,
 }
 
 FASTCGI_HELP = r"""
@@ -63,6 +64,8 @@ Optional Fcgi settings: (setting=value)
   outlog=FILE          write stdout to this file.
   errlog=FILE          write stderr to this file.
   umask=UMASK          umask to use when daemonizing, in octal notation (default 022).
+  socketumask=UMASK    umask to use when creating the socket, in octal notation
+                       (default 022).
 
 Examples:
   Run a "standard" fastcgi process on a file-descriptor
@@ -163,6 +166,9 @@ def runfastcgi(argset=[], **kwargs):
             return fastcgi_help("ERROR: Invalid option for daemonize "
                                 "parameter.")
 
+    if options['socketumask'] and not options['socket']:
+        return fastcgi_help("ERROR: socketumask requires socket parameter")
+
     daemon_kwargs = {}
     if options['outlog']:
         daemon_kwargs['out_log'] = options['outlog']
@@ -171,6 +177,9 @@ def runfastcgi(argset=[], **kwargs):
     if options['umask']:
         daemon_kwargs['umask'] = int(options['umask'], 8)
 
+    if options['socketumask']:
+        wsgi_opts['umask'] = int(options['socketumask'], 8)
+
     if daemonize:
         from django.utils.daemonize import become_daemon
         become_daemon(our_home_dir=options["workdir"], **daemon_kwargs)
diff --git a/docs/howto/deployment/fastcgi.txt b/docs/howto/deployment/fastcgi.txt
index 507e50d..3796be8 100644
--- a/docs/howto/deployment/fastcgi.txt
+++ b/docs/howto/deployment/fastcgi.txt
@@ -115,8 +115,15 @@ Running a preforked server on a Unix domain socket::
     Django's default umask requires that the web server and the Django fastcgi
     process be run with the same group **and** user. For increased security,
     you can run them under the same group but as different users. If you do
-    this, you will need to set the umask to 0002 using the ``umask`` argument
-    to ``runfcgi``.
+    this, you will need to set the umask to 0007 using the ``socketumask``
+    argument to ``runfcgi``.
+
+    .. versionchanged:: dev
+
+        Up to Django 1.6 there was only the ``umask`` argument which sets the
+        umask for all files created by Django (not only the socket) and does
+        not work with ``daemonize=false``.
+
 
 Run without daemonizing (backgrounding) the process (good for debugging)::
 
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
--- /dev/null
+++ b/tests/fcgi/manage.py
@@ -0,0 +1,9 @@
+import os
+import sys
+
+if __name__ == "__main__":
+    os.environ.setdefault("DJANGO_SETTINGS_MODULE", "settings")
+
+    from django.core.management import execute_from_command_line
+
+    execute_from_command_line(sys.argv)
diff --git a/tests/fcgi/settings.py b/tests/fcgi/settings.py
new file mode 100644
index 0000000..62f17c0
--- /dev/null
+++ b/tests/fcgi/settings.py
@@ -0,0 +1,3 @@
+ROOT_URLCONF = 'urls'
+DEBUG = True
+SECRET_KEY = 'nawrulez'
diff --git a/tests/fcgi/tests.py b/tests/fcgi/tests.py
new file mode 100644
index 0000000..307ed4f
--- /dev/null
+++ b/tests/fcgi/tests.py
@@ -0,0 +1,335 @@
+from __future__ import unicode_literals
+
+import unittest
+import subprocess
+import sys
+import os
+import time
+import threading
+import signal
+import tempfile
+from django.utils.six import StringIO
+
+try:
+    import flup
+    no_flup = False
+except ImportError:
+    no_flup = True
+
+"""
+Waits for a process to terminate for a maximum of `timeout` seconds.
+If the timeout is reached, the process is killed.
+
+Returns True if the process terminated within the timeout interval.
+"""
+def wait_or_kill(proc, timeout):
+    def wait_thread():
+        proc.wait()
+
+    thread = threading.Thread(target=wait_thread)
+    thread.start()
+
+    thread.join(timeout)
+    if thread.is_alive():
+        proc.kill()
+        thread.join()
+        return False
+
+    return True
+
+
+if os.name == 'posix':
+    def pid_exists(pid):
+        """Check whether pid exists in the current process table."""
+        import errno
+        # Adapted from http://stackoverflow.com/a/6940314/1777162
+        if pid < 0:
+            return False
+        try:
+            os.kill(pid, 0)
+        except OSError as e:
+            return e.errno == errno.EPERM
+        else:
+            return True
+else:
+    def pid_exists(pid):
+        import ctypes
+        kernel32 = ctypes.windll.kernel32
+        SYNCHRONIZE = 0x100000
+
+        process = kernel32.OpenProcess(SYNCHRONIZE, 0, pid)
+        if process != 0:
+            kernel32.CloseHandle(process)
+            return True
+        else:
+            return False
+
+
+class BaseFastCGITest(object):
+    def setUp(self):
+        self.server_running = False
+        self.host = None
+        self.port = None
+        self.socket = None
+        self.args = []
+        self.test_file = None
+        self.pidfile = None
+        self.umask = None
+        self.socketumask = None
+
+    def assertFileMatchesUmask(self, filepath, umask):
+        return self.assertEqual(
+                    oct((os.stat(filepath).st_mode & 0o777) & ~0o111),
+                    oct((0o777 - umask) & ~0o111))
+
+    def tearDown(self):
+        if self.server_running:
+            self.stop_fcgi_server()
+
+        if self.socket:
+            os.unlink(self.socket)
+
+        if self.test_file:
+            os.unlink(self.test_file)
+
+        if self.pidfile:
+            os.unlink(self.pidfile)
+
+    def start_fcgi_server(self):
+        python = sys.executable
+        args = ['python', 'manage.py', 'runfcgi'] + self.args
+        path = os.path.dirname(__file__)
+
+        repo = os.path.join(os.path.dirname(__file__), '../..')
+
+        self.proc = subprocess.Popen(args, executable=python, env=os.environ)
+
+    def sleep(self):
+        # Sleep for half second
+        time.sleep(0.5)
+
+    def talk_to_server(self, url):
+        def set_status(self, got_status, got_headers):
+            self['status'] = got_status
+
+        import flup.client.fcgi_app
+        app = flup.client.fcgi_app.FCGIApp(connect=self.flup_connection_object)
+
+        env = {
+           'SCRIPT_FILENAME': '/django.fcgi',
+           'QUERY_STRING': '',
+           'REQUEST_METHOD': 'GET',
+           'SCRIPT_NAME': url,
+           'REQUEST_URI': url,
+           'GATEWAY_INTERFACE': 'CGI/1.1',
+           'REDIRECT_STATUS': '200',
+           'CONTENT_TYPE': '',
+           'CONTENT_LENGTH': '0',
+           'DOCUMENT_ROOT': '/',
+           'DOCUMENT_ROOT': '/var/www/',
+           'REMOTE_ADDR': '127.0.0.1',
+           'SERVER_NAME': 'foo',
+           'SERVER_PORT': '80',
+           'SERVER_PROTOCOL': 'HTTP/1.1',
+           'wsgi.input': StringIO('GET / HTTP/1.1\r\n\r\n'),
+           'wsgi.errors': StringIO(),
+        }
+
+        status_dict = {}
+        response = app(env, set_status.__get__(status_dict))
+
+        return (status_dict['status'], ''.join(response))
+
+    def stop_fcgi_server(self):
+        # Send SIGINT to process
+        os.kill(self.pid, signal.SIGINT)
+
+        # Fail if not stopped within 1 second
+        self.assertTrue(wait_or_kill(self.proc, 2), msg='The server did not stop')
+
+        self.server_running = False
+
+    @unittest.skipIf(no_flup, 'Flup is required to test runfcgi')
+    def test_all(self):
+        self.set_up_connection_params()
+        self.set_up_daemonize()
+        self.set_up_mp_mode()
+        self.set_up_umask()
+        self.set_up_socketumask()
+
+        self.start_fcgi_server()
+        self.sleep()
+        self.check_alive()
+        self.check_socket_permissions() # if any
+        self.do_test()
+        self.stop_fcgi_server()
+
+    def check_socket_permissions(self):
+        pass
+
+    def set_up_umask(self):
+        pass
+
+    def set_up_socketumask(self):
+        pass
+
+class TCPTest(object):
+    def set_up_connection_params(self):
+        self.port = 3333 # Bad luck if already used
+        self.host = '127.0.0.1'
+
+        self.args += ['host=%s' % self.host,
+                      'port=%d' % self.port ]
+
+    @property
+    def flup_connection_object(self):
+        return (self.host, self.port)
+
+
+class UNIXSocketTest(object):
+    def set_up_connection_params(self):
+        self.socket = '/tmp/django-test-case-socket.sock'
+
+        self.args += ['socket=%s' % self.socket]
+
+    @property
+    def flup_connection_object(self):
+        return str(self.socket)
+
+
+class NotDaemonizeTest(object):
+    def set_up_daemonize(self):
+        self.args += ['daemonize=false']
+
+    def check_alive(self):
+        self.pid = self.proc.pid
+
+        # Process has not terminated (i.e. by error)
+        self.assertIsNone(self.proc.poll(), msg='The server was prematurely shut down')
+
+        self.server_running = True
+
+
+class DaemonizeTest(object):
+    def set_up_daemonize(self):
+        self.pidfile = os.path.join(tempfile.gettempdir(),
+                                    'django-test-case-socket.pid')
+
+        self.args += ['daemonize=true',
+                      'pidfile=%s' % self.pidfile]
+
+    def check_alive(self):
+        with open(self.pidfile, 'r') as f:
+            self.pid = int(f.read())
+
+        # Check there is a process with such PID
+        self.assertTrue(pid_exists(self.pid))
+
+        self.server_running = True
+
+
+class UmaskTest(object):
+    def set_up_umask(self):
+        self.umask = 0o002
+
+        self.args += ['umask=%#03o' % self.umask]
+
+
+class SocketumaskTest(object):
+    def set_up_socketumask(self):
+        self.socketumask = 0o007
+
+        self.args += ['socketumask=%#03o' % self.socketumask]
+
+    def check_socket_permissions(self):
+        self.assertFileMatchesUmask(self.socket, self.socketumask)
+
+class HelloWorldTest(object):
+    def do_test(self):
+        status, response = self.talk_to_server('/')
+
+        self.assertEqual(status, '200 OK')
+        self.assertEqual(response, 'Hello World')
+
+
+class TouchFileTest(object):
+    def do_test(self):
+        self.test_file = os.path.join(tempfile.gettempdir(), 'django-test-touch-file')
+        status, response = self.talk_to_server('/touch_tmp/')
+
+        self.assertEqual(status, '200 OK')
+        self.assertEqual(response, 'File created')
+        self.assertTrue(os.path.exists(self.test_file))
+
+        if self.umask:
+            self.assertFileMatchesUmask(self.test_file, self.umask)
+
+class ThreadedTest(object):
+    def set_up_mp_mode(self):
+        self.args += ['method=threaded']
+
+
+class PreforkTest(object):
+    def set_up_mp_mode(self):
+        self.args += ['method=prefork']
+
+
+tests = """
+ ----------------------------------------------------------------------------------------------------------
+|         ||     ||  Transport || Daemonize ||    Umask   || SocketUmask ||    Method     ||     Test      |
+| Test No || Win ||------------||-----------||------------||-------------||---------------||---------------|
+|         ||     || TCP | UNIX ||  F  |  T  || No | Umask || No | SUmask || Fork | Thread || Hello | Touch |
+|----------------------------------------------------------------------------------------------------------|
+|    1    ||  *  ||  *  |      ||  *  |     || *  |       || *  |        ||      |   *    ||   *   |       |
+|    2    ||  *  ||  *  |      ||     |  *  || *  |       || *  |        ||      |   *    ||   *   |       |
+|    3    ||     ||  *  |      ||     |  *  ||    |   *   || *  |        ||  *   |        ||       |   *   |
+|    4    ||  *  ||  *  |      ||  *  |     || *  |       || *  |        ||      |   *    ||   *   |       |
+|    5    ||  *  ||  *  |      ||     |  *  || *  |       || *  |        ||      |   *    ||   *   |       |
+|    6    ||     ||     |  *   ||  *  |     || *  |       || *  |        ||  *   |        ||   *   |       |
+|    7    ||     ||     |  *   ||  *  |     || *  |       ||    |    *   ||  *   |        ||   *   |       |
+|    8    ||     ||     |  *   ||     |  *  ||    |   *   ||    |    *   ||  *   |        ||       |   *   |
+ ----------------------------------------------------------------------------------------------------------
+"""
+
+def create_test_cases(env, tests_str):
+    def marked_subcol(col):
+        ticks = [nsubcol
+                 for (nsubcol, subcol)
+                 in enumerate(col.split('|'))
+                 if '*' in subcol]
+        if len(ticks) == 1:
+            return ticks[0]
+        else:
+            raise RuntimeError('Wrong number of marked columns')
+
+    rows = tests_str.split('\n')[6:-2] # Remove header and footer
+    for row in rows:
+        big_cols = row.split('||')
+        test_no = int(
+                big_cols[0][1:] # Remove leading |
+                )
+        class_name = str('Test%d' % test_no)
+        parents = tuple(x for x in [
+                (TCPTest          , UNIXSocketTest ) [marked_subcol(big_cols[2])],
+                (NotDaemonizeTest , DaemonizeTest  ) [marked_subcol(big_cols[3])],
+                (None             , UmaskTest      ) [marked_subcol(big_cols[4])],
+                (None             , SocketumaskTest) [marked_subcol(big_cols[5])],
+                (PreforkTest      , ThreadedTest   ) [marked_subcol(big_cols[6])],
+                (HelloWorldTest   , TouchFileTest  ) [marked_subcol(big_cols[7])],
+                BaseFastCGITest,
+                unittest.TestCase,
+                ] if x is not None)
+
+        cls = type(class_name, parents, {})
+
+        no_win = '*' not in big_cols[1]
+        if no_win:
+            cls.test_all = unittest.skipIf(sys.platform.startswith('win'),
+                    'Windows does not support UNIX domain sockets nor fork')(cls.test_all)
+
+        env[class_name] = cls
+
+create_test_cases(locals(), tests)
+
+if __name__=="__main__":
+    unittest.main()
diff --git a/tests/fcgi/urls.py b/tests/fcgi/urls.py
new file mode 100644
index 0000000..9f8b1b9
--- /dev/null
+++ b/tests/fcgi/urls.py
@@ -0,0 +1,19 @@
+from django.conf.urls import patterns, url
+from django.http import HttpResponse
+import tempfile
+import os.path
+
+def hello(request):
+    return HttpResponse('Hello World')
+
+def touch_tmp(request):
+    path = os.path.join(tempfile.gettempdir(), 'django-test-touch-file')
+    with open(path, 'w'):
+        pass
+
+    return HttpResponse('File created')
+
+urlpatterns = patterns('',
+    url(r'^$', hello),
+    url(r'^touch_tmp/', touch_tmp),
+)
-- 
1.8.3.2

