CVE-2024-4030: Insufficient Information (NVD-CWE-noinfo)
value: On Windows a directory returned by tempfile.mkdtemp() would not always have permissions set to restrict reading and writing to the temporary directory by other users, instead usually inheriting the correct permissions from the default location. Alternate configurations or users without a profile directory may not have the intended permissions.

If you’re not using Windows or haven’t changed the temporary directory location then you aren’t affected by this vulnerability. On other platforms the returned directory is consistently readable and writable only by the current user.

This issue was caused by Python not supporting Unix permissions on Windows. The fix adds support for Unix “700” for the mkdir function on Windows which is used by mkdtemp() to ensure the newly created directory has the proper permissions.

CWE description: There is insufficient information about the issue to classify it; details are unkown or unspecified.
Published: 2024-05-07T21:15Z
Repository: https://github.com/python/cpython
Language: Other
Files changed: Doc/library/os.rst, Lib/test/test_os.py, Lib/test/test_tempfile.py, Misc/NEWS.d/next/Windows/2024-05-01-20-57-09.gh-issue-118486.K44KJG.rst, Modules/posixmodule.c
Commit date: 2024-05-02 15:20:43 +0100
Commit message: gh-118486: Support mkdir(mode=0o700) on Windows (GH-118488)


Diff: diff --git a/Doc/library/os.rst b/Doc/library/os.rst
index 844b5f26d8d4..6c92eed9c063 100644
--- a/Doc/library/os.rst
+++ b/Doc/library/os.rst
@@ -2428,10 +2428,14 @@ features:
    value is first masked out.  If bits other than the last 9 (i.e. the last 3
    digits of the octal representation of the *mode*) are set, their meaning is
    platform-dependent.  On some platforms, they are ignored and you should call
    :func:`chmod` explicitly to set them.
 
+   On Windows, a *mode* of ``0o700`` is specifically handled to apply access
+   control to the new directory such that only the current user and
+   administrators have access. Other values of *mode* are ignored.
+
    This function can also support :ref:`paths relative to directory descriptors
    <dir_fd>`.
 
    It is also possible to create temporary directories; see the
    :mod:`tempfile` module's :func:`tempfile.mkdtemp` function.
@@ -2442,10 +2446,13 @@ features:
       Added the *dir_fd* parameter.
 
    .. versionchanged:: 3.6
       Accepts a :term:`path-like object`.
 
+   .. versionchanged:: 3.13
+      Windows now handles a *mode* of ``0o700``.
+
 
 .. function:: makedirs(name, mode=0o777, exist_ok=False)
 
    .. index::
       single: directory; creating
diff --git a/Lib/test/test_os.py b/Lib/test/test_os.py
index eaa676673f8a..9c9c8536dc75 100644
--- a/Lib/test/test_os.py
+++ b/Lib/test/test_os.py
@@ -1809,10 +1809,29 @@ def test_exist_ok_existing_regular_file(self):
         self.assertRaises(OSError, os.makedirs, path)
         self.assertRaises(OSError, os.makedirs, path, exist_ok=False)
         self.assertRaises(OSError, os.makedirs, path, exist_ok=True)
         os.remove(path)
 
+    @unittest.skipUnless(os.name == 'nt', "requires Windows")
+    def test_win32_mkdir_700(self):
+        base = os_helper.TESTFN
+        path1 = os.path.join(os_helper.TESTFN, 'dir1')
+        path2 = os.path.join(os_helper.TESTFN, 'dir2')
+        # mode=0o700 is special-cased to override ACLs on Windows
+        # There's no way to know exactly how the ACLs will look, so we'll
+        # check that they are different from a regularly created directory.
+        os.mkdir(path1, mode=0o700)
+        os.mkdir(path2, mode=0o777)
+
+        out1 = subprocess.check_output(["icacls.exe", path1], encoding="oem")
+        out2 = subprocess.check_output(["icacls.exe", path2], encoding="oem")
+        os.rmdir(path1)
+        os.rmdir(path2)
+        out1 = out1.replace(path1, "<PATH>")
+        out2 = out2.replace(path2, "<PATH>")
+        self.assertNotEqual(out1, out2)
+
     def tearDown(self):
         path = os.path.join(os_helper.TESTFN, 'dir1', 'dir2', 'dir3',
                             'dir4', 'dir5', 'dir6')
         # If the tests failed, the bottom-most directory ('../dir6')
         # may not have been created, so we look for the outermost directory
diff --git a/Lib/test/test_tempfile.py b/Lib/test/test_tempfile.py
index b64b6a4f2bae..19ddeaa169bf 100644
--- a/Lib/test/test_tempfile.py
+++ b/Lib/test/test_tempfile.py
@@ -11,10 +11,11 @@
 import stat
 import types
 import weakref
 import gc
 import shutil
+import subprocess
 from unittest import mock
 
 import unittest
 from test import support
 from test.support import os_helper
@@ -801,10 +802,37 @@ def test_mode(self):
                 expected = user * (1 + 8 + 64)
             self.assertEqual(mode, expected)
         finally:
             os.rmdir(dir)
 
+    @unittest.skipUnless(os.name == "nt", "Only on Windows.")
+    def test_mode_win32(self):
+        # Use icacls.exe to extract the users with some level of access
+        # Main thing we are testing is that the BUILTIN\Users group has
+        # no access. The exact ACL is going to vary based on which user
+        # is running the test.
+        dir = self.do_create()
+        try:
+            out = subprocess.check_output(["icacls.exe", dir], encoding="oem").casefold()
+        finally:
+            os.rmdir(dir)
+
+        dir = dir.casefold()
+        users = set()
+        found_user = False
+        for line in out.strip().splitlines():
+            acl = None
+            # First line of result includes our directory
+            if line.startswith(dir):
+                acl = line.removeprefix(dir).strip()
+            elif line and line[:1].isspace():
+                acl = line.strip()
+            if acl:
+                users.add(acl.partition(":")[0])
+
+        self.assertNotIn(r"BUILTIN\Users".casefold(), users)
+
     def test_collision_with_existing_file(self):
         # mkdtemp tries another name when a file with
         # the chosen name already exists
         with _inside_empty_temp_dir(), \
              _mock_candidate_names('aaa', 'aaa', 'bbb'):
diff --git a/Misc/NEWS.d/next/Windows/2024-05-01-20-57-09.gh-issue-118486.K44KJG.rst b/Misc/NEWS.d/next/Windows/2024-05-01-20-57-09.gh-issue-118486.K44KJG.rst
new file mode 100644
index 000000000000..cdbce9a0bebf
--- /dev/null
+++ b/Misc/NEWS.d/next/Windows/2024-05-01-20-57-09.gh-issue-118486.K44KJG.rst
@@ -0,0 +1,2 @@
+:func:`os.mkdir` now accepts *mode* of ``0o700`` to restrict the new
+directory to the current user.
diff --git a/Modules/posixmodule.c b/Modules/posixmodule.c
index 722159a39d09..f9533577a8fa 100644
--- a/Modules/posixmodule.c
+++ b/Modules/posixmodule.c
@@ -35,10 +35,12 @@
 #    include <pathcch.h>
 #  endif
 #  include <winioctl.h>
 #  include <lmcons.h>             // UNLEN
 #  include "osdefs.h"             // SEP
+#  include <aclapi.h>             // SetEntriesInAcl
+#  include <sddl.h>               // SDDL_REVISION_1
 #  if defined(MS_WINDOWS_DESKTOP) || defined(MS_WINDOWS_SYSTEM)
 #    define HAVE_SYMLINK
 #  endif /* MS_WINDOWS_DESKTOP | MS_WINDOWS_SYSTEM */
 #endif
 
@@ -5537,10 +5539,137 @@ os__path_normpath_impl(PyObject *module, PyObject *path)
     PyObject *result = PyUnicode_FromWideChar(norm_path, norm_len);
     PyMem_Free(buffer);
     return result;
 }
 
+#ifdef MS_WINDOWS
+
+/* We centralise SECURITY_ATTRIBUTE initialization based around
+templates that will probably mostly match common POSIX mode settings.
+The _Py_SECURITY_ATTRIBUTE_DATA structure contains temporary data, as
+a constructed SECURITY_ATTRIBUTE structure typically refers to memory
+that has to be alive while it's being used.
+
+Typical use will look like:
+    SECURITY_ATTRIBUTES *pSecAttr = NULL;
+    struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
+    int error, error2;
+
+    Py_BEGIN_ALLOW_THREADS
+    switch (mode) {
+    case 0x1C0: // 0o700
+        error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
+        break;
+    ...
+    default:
+        error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
+        break;
+    }
+
+    if (!error) {
+        // do operation, passing pSecAttr
+    }
+
+    // Unconditionally clear secAttrData.
+    error2 = clearSecurityAttributes(&pSecAttr, &secAttrData);
+    if (!error) {
+        error = error2;
+    }
+    Py_END_ALLOW_THREADS
+
+    if (error) {
+        PyErr_SetFromWindowsErr(error);
+        return NULL;
+    }
+*/
+
+struct _Py_SECURITY_ATTRIBUTE_DATA {
+    SECURITY_ATTRIBUTES securityAttributes;
+    PACL acl;
+    SECURITY_DESCRIPTOR sd;
+    EXPLICIT_ACCESS_W ea[4];
+};
+
+static int
+initializeDefaultSecurityAttributes(
+    PSECURITY_ATTRIBUTES *securityAttributes,
+    struct _Py_SECURITY_ATTRIBUTE_DATA *data
+) {
+    assert(securityAttributes);
+    assert(data);
+    *securityAttributes = NULL;
+    memset(data, 0, sizeof(*data));
+    return 0;
+}
+
+static int
+initializeMkdir700SecurityAttributes(
+    PSECURITY_ATTRIBUTES *securityAttributes,
+    struct _Py_SECURITY_ATTRIBUTE_DATA *data
+) {
+    assert(securityAttributes);
+    assert(data);
+    *securityAttributes = NULL;
+    memset(data, 0, sizeof(*data));
+
+    if (!InitializeSecurityDescriptor(&data->sd, SECURITY_DESCRIPTOR_REVISION)
+        || !SetSecurityDescriptorGroup(&data->sd, NULL, TRUE)) {
+        return GetLastError();
+    }
+
+    data->securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
+    data->ea[0].grfAccessPermissions = GENERIC_ALL;
+    data->ea[0].grfAccessMode = SET_ACCESS;
+    data->ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
+    data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
+    data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
+    data->ea[0].Trustee.ptstrName = L"CURRENT_USER";
+
+    data->ea[1].grfAccessPermissions = GENERIC_ALL;
+    data->ea[1].grfAccessMode = SET_ACCESS;
+    data->ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
+    data->ea[1].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
+    data->ea[1].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
+    data->ea[1].Trustee.ptstrName = L"SYSTEM";
+
+    data->ea[2].grfAccessPermissions = GENERIC_ALL;
+    data->ea[2].grfAccessMode = SET_ACCESS;
+    data->ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
+    data->ea[2].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
+    data->ea[2].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
+    data->ea[2].Trustee.ptstrName = L"ADMINISTRATORS";
+
+    int r = SetEntriesInAclW(3, data->ea, NULL, &data->acl);
+    if (r) {
+        return r;
+    }
+    if (!SetSecurityDescriptorDacl(&data->sd, TRUE, data->acl, FALSE)) {
+        return GetLastError();
+    }
+    data->securityAttributes.lpSecurityDescriptor = &data->sd;
+    *securityAttributes = &data->securityAttributes;
+    return 0;
+}
+
+static int
+clearSecurityAttributes(
+    PSECURITY_ATTRIBUTES *securityAttributes,
+    struct _Py_SECURITY_ATTRIBUTE_DATA *data
+) {
+    assert(securityAttributes);
+    assert(data);
+    *securityAttributes = NULL;
+    if (data->acl) {
+        if (LocalFree((void *)data->acl)) {
+            return GetLastError();
+        }
+    }
+    return 0;
+}
+
+#endif
+
 /*[clinic input]
 os.mkdir
 
     path : path_t
 
@@ -5566,10 +5695,16 @@ value is first masked out.
 static PyObject *
 os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
 /*[clinic end generated code: output=a70446903abe821f input=a61722e1576fab03]*/
 {
     int result;
+#ifdef MS_WINDOWS
+    int error = 0;
+    int pathError = 0;
+    SECURITY_ATTRIBUTES *pSecAttr = NULL;
+    struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
+#endif
 #ifdef HAVE_MKDIRAT
     int mkdirat_unavailable = 0;
 #endif
 
     if (PySys_Audit("os.mkdir", "Oii", path->object, mode,
@@ -5577,15 +5712,34 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
         return NULL;
     }
 
 #ifdef MS_WINDOWS
     Py_BEGIN_ALLOW_THREADS
-    result = CreateDirectoryW(path->wide, NULL);
+    switch (mode) {
+    case 0x1C0: // 0o700
+        error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
+        break;
+    default:
+        error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
+        break;
+    }
+    if (!error) {
+        result = CreateDirectoryW(path->wide, pSecAttr);
+        error = clearSecurityAttributes(&pSecAttr, &secAttrData);
+    } else {
+        // Ignore error from "clear" - we have a more interesting one already
+        clearSecurityAttributes(&pSecAttr, &secAttrData);
+    }
     Py_END_ALLOW_THREADS
 
-    if (!result)
+    if (error) {
+        PyErr_SetFromWindowsErr(error);
+        return NULL;
+    }
+    if (!result) {
         return path_error(path);
+    }
 #else
     Py_BEGIN_ALLOW_THREADS
 #if HAVE_MKDIRAT
     if (dir_fd != DEFAULT_DIR_FD) {
       if (HAVE_MKDIRAT_RUNTIME) {

Vulnerable code:     result = CreateDirectoryW(path->wide, NULL);
    if (!result)
Fixed code:    On Windows, a *mode* of ``0o700`` is specifically handled to apply access
   control to the new directory such that only the current user and
   administrators have access. Other values of *mode* are ignored.

   .. versionchanged:: 3.13
      Windows now handles a *mode* of ``0o700``.

    @unittest.skipUnless(os.name == 'nt', "requires Windows")
    def test_win32_mkdir_700(self):
        base = os_helper.TESTFN
        path1 = os.path.join(os_helper.TESTFN, 'dir1')
        path2 = os.path.join(os_helper.TESTFN, 'dir2')
        # mode=0o700 is special-cased to override ACLs on Windows
        # There's no way to know exactly how the ACLs will look, so we'll
        # check that they are different from a regularly created directory.
        os.mkdir(path1, mode=0o700)
        os.mkdir(path2, mode=0o777)

        out1 = subprocess.check_output(["icacls.exe", path1], encoding="oem")
        out2 = subprocess.check_output(["icacls.exe", path2], encoding="oem")
        os.rmdir(path1)
        os.rmdir(path2)
        out1 = out1.replace(path1, "<PATH>")
        out2 = out2.replace(path2, "<PATH>")
        self.assertNotEqual(out1, out2)

import subprocess
    @unittest.skipUnless(os.name == "nt", "Only on Windows.")
    def test_mode_win32(self):
        # Use icacls.exe to extract the users with some level of access
        # Main thing we are testing is that the BUILTIN\Users group has
        # no access. The exact ACL is going to vary based on which user
        # is running the test.
        dir = self.do_create()
        try:
            out = subprocess.check_output(["icacls.exe", dir], encoding="oem").casefold()
        finally:
            os.rmdir(dir)

        dir = dir.casefold()
        users = set()
        found_user = False
        for line in out.strip().splitlines():
            acl = None
            # First line of result includes our directory
            if line.startswith(dir):
                acl = line.removeprefix(dir).strip()
            elif line and line[:1].isspace():
                acl = line.strip()
            if acl:
                users.add(acl.partition(":")[0])

        self.assertNotIn(r"BUILTIN\Users".casefold(), users)

:func:`os.mkdir` now accepts *mode* of ``0o700`` to restrict the new
directory to the current user.
#  include <aclapi.h>             // SetEntriesInAcl
#  include <sddl.h>               // SDDL_REVISION_1
#ifdef MS_WINDOWS

/* We centralise SECURITY_ATTRIBUTE initialization based around
templates that will probably mostly match common POSIX mode settings.
The _Py_SECURITY_ATTRIBUTE_DATA structure contains temporary data, as
a constructed SECURITY_ATTRIBUTE structure typically refers to memory
that has to be alive while it's being used.

Typical use will look like:
    SECURITY_ATTRIBUTES *pSecAttr = NULL;
    struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
    int error, error2;

    Py_BEGIN_ALLOW_THREADS
    switch (mode) {
    case 0x1C0: // 0o700
        error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
        break;
    ...
    default:
        error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
        break;
    }

    if (!error) {
        // do operation, passing pSecAttr
    }

    // Unconditionally clear secAttrData.
    error2 = clearSecurityAttributes(&pSecAttr, &secAttrData);
    if (!error) {
        error = error2;
    }
    Py_END_ALLOW_THREADS

    if (error) {
        PyErr_SetFromWindowsErr(error);
        return NULL;
    }
*/

struct _Py_SECURITY_ATTRIBUTE_DATA {
    SECURITY_ATTRIBUTES securityAttributes;
    PACL acl;
    SECURITY_DESCRIPTOR sd;
    EXPLICIT_ACCESS_W ea[4];
};

static int
initializeDefaultSecurityAttributes(
    PSECURITY_ATTRIBUTES *securityAttributes,
    struct _Py_SECURITY_ATTRIBUTE_DATA *data
) {
    assert(securityAttributes);
    assert(data);
    *securityAttributes = NULL;
    memset(data, 0, sizeof(*data));
    return 0;
}

static int
initializeMkdir700SecurityAttributes(
    PSECURITY_ATTRIBUTES *securityAttributes,
    struct _Py_SECURITY_ATTRIBUTE_DATA *data
) {
    assert(securityAttributes);
    assert(data);
    *securityAttributes = NULL;
    memset(data, 0, sizeof(*data));

    if (!InitializeSecurityDescriptor(&data->sd, SECURITY_DESCRIPTOR_REVISION)
        || !SetSecurityDescriptorGroup(&data->sd, NULL, TRUE)) {
        return GetLastError();
    }

    data->securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
    data->ea[0].grfAccessPermissions = GENERIC_ALL;
    data->ea[0].grfAccessMode = SET_ACCESS;
    data->ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
    data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
    data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
    data->ea[0].Trustee.ptstrName = L"CURRENT_USER";

    data->ea[1].grfAccessPermissions = GENERIC_ALL;
    data->ea[1].grfAccessMode = SET_ACCESS;
    data->ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
    data->ea[1].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
    data->ea[1].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
    data->ea[1].Trustee.ptstrName = L"SYSTEM";

    data->ea[2].grfAccessPermissions = GENERIC_ALL;
    data->ea[2].grfAccessMode = SET_ACCESS;
    data->ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
    data->ea[2].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
    data->ea[2].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
    data->ea[2].Trustee.ptstrName = L"ADMINISTRATORS";

    int r = SetEntriesInAclW(3, data->ea, NULL, &data->acl);
    if (r) {
        return r;
    }
    if (!SetSecurityDescriptorDacl(&data->sd, TRUE, data->acl, FALSE)) {
        return GetLastError();
    }
    data->securityAttributes.lpSecurityDescriptor = &data->sd;
    *securityAttributes = &data->securityAttributes;
    return 0;
}

static int
clearSecurityAttributes(
    PSECURITY_ATTRIBUTES *securityAttributes,
    struct _Py_SECURITY_ATTRIBUTE_DATA *data
) {
    assert(securityAttributes);
    assert(data);
    *securityAttributes = NULL;
    if (data->acl) {
        if (LocalFree((void *)data->acl)) {
            return GetLastError();
        }
    }
    return 0;
}

#endif

#ifdef MS_WINDOWS
    int error = 0;
    int pathError = 0;
    SECURITY_ATTRIBUTES *pSecAttr = NULL;
    struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
#endif
    switch (mode) {
    case 0x1C0: // 0o700
        error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
        break;
    default:
        error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
        break;
    }
    if (!error) {
        result = CreateDirectoryW(path->wide, pSecAttr);
        error = clearSecurityAttributes(&pSecAttr, &secAttrData);
    } else {
        // Ignore error from "clear" - we have a more interesting one already
        clearSecurityAttributes(&pSecAttr, &secAttrData);
    }
    if (error) {
        PyErr_SetFromWindowsErr(error);
        return NULL;
    }
    if (!result) {
    }