Skip to content

Commit c85fa1f

Browse files
committed
pythongh-118486: Support mkdir(mode=0o700) on Windows
1 parent ebef3c5 commit c85fa1f

File tree

6 files changed

+238
-2
lines changed

6 files changed

+238
-2
lines changed

Doc/library/os.rst

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2356,6 +2356,10 @@ features:
23562356
platform-dependent. On some platforms, they are ignored and you should call
23572357
:func:`chmod` explicitly to set them.
23582358

2359+
On Windows, a *mode* of ``0o700`` is specifically handled to apply access
2360+
control to the new directory such that only the current user and
2361+
administrators have access. Other values of *mode* are ignored.
2362+
23592363
This function can also support :ref:`paths relative to directory descriptors
23602364
<dir_fd>`.
23612365

@@ -2370,6 +2374,9 @@ features:
23702374
.. versionchanged:: 3.6
23712375
Accepts a :term:`path-like object`.
23722376

2377+
.. versionchanged:: 3.12.4
2378+
Windows now handles a *mode* of ``0o700``.
2379+
23732380

23742381
.. function:: makedirs(name, mode=0o777, exist_ok=False)
23752382

Doc/whatsnew/3.12.rst

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -778,6 +778,13 @@ os
778778
Both functions may be significantly faster on newer releases of
779779
Windows. (Contributed by Steve Dower in :gh:`99726`.)
780780

781+
* As of 3.12.4, :func:`os.mkdir` and :func:`os.makedirs` on Windows
782+
now support passing a *mode* value of ``0o700`` to apply access
783+
control to the new directory. This implicitly affects
784+
:func:`tempfile.mkdtemp` and is a mitigation for :cve:`2024-4030`.
785+
Other values for *mode* continue to be ignored.
786+
(Contributed by Steve Dower in :gh:`118486`.)
787+
781788
os.path
782789
-------
783790

@@ -925,6 +932,10 @@ tempfile
925932
*delete_on_close* (Contributed by Evgeny Zorin in :gh:`58451`.)
926933
* :func:`tempfile.mkdtemp` now always returns an absolute path, even if the
927934
argument provided to the *dir* parameter is a relative path.
935+
* As of 3.12.4 on Windows, the default mode ``0o700`` used by
936+
:func:`tempfile.mkdtemp` now limits access to the new directory due to
937+
changes to :func:`os.mkdir`. This is a mitigation for :cve:`2024-4030`.
938+
(Contributed by Steve Dower in :gh:`118486`.)
928939

929940
threading
930941
---------

Lib/test/test_os.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1797,6 +1797,25 @@ def test_exist_ok_existing_regular_file(self):
17971797
self.assertRaises(OSError, os.makedirs, path, exist_ok=True)
17981798
os.remove(path)
17991799

1800+
@unittest.skipUnless(os.name == 'nt', "requires Windows")
1801+
def test_win32_mkdir_700(self):
1802+
base = os_helper.TESTFN
1803+
path1 = os.path.join(os_helper.TESTFN, 'dir1')
1804+
path2 = os.path.join(os_helper.TESTFN, 'dir2')
1805+
# mode=0o700 is special-cased to override ACLs on Windows
1806+
# There's no way to know exactly how the ACLs will look, so we'll
1807+
# check that they are different from a regularly created directory.
1808+
os.mkdir(path1, mode=0o700)
1809+
os.mkdir(path2, mode=0o777)
1810+
1811+
out1 = subprocess.check_output(["icacls.exe", path1], encoding="oem")
1812+
out2 = subprocess.check_output(["icacls.exe", path2], encoding="oem")
1813+
os.rmdir(path1)
1814+
os.rmdir(path2)
1815+
out1 = out1.replace(path1, "<PATH>")
1816+
out2 = out2.replace(path2, "<PATH>")
1817+
self.assertNotEqual(out1, out2)
1818+
18001819
def tearDown(self):
18011820
path = os.path.join(os_helper.TESTFN, 'dir1', 'dir2', 'dir3',
18021821
'dir4', 'dir5', 'dir6')

Lib/test/test_tempfile.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import weakref
1414
import gc
1515
import shutil
16+
import subprocess
1617
from unittest import mock
1718

1819
import unittest
@@ -803,6 +804,33 @@ def test_mode(self):
803804
finally:
804805
os.rmdir(dir)
805806

807+
@unittest.skipUnless(os.name == "nt", "Only on Windows.")
808+
def test_mode_win32(self):
809+
# Use icacls.exe to extract the users with some level of access
810+
# Main thing we are testing is that the BUILTIN\Users group has
811+
# no access. The exact ACL is going to vary based on which user
812+
# is running the test.
813+
dir = self.do_create()
814+
try:
815+
out = subprocess.check_output(["icacls.exe", dir], encoding="oem").casefold()
816+
finally:
817+
os.rmdir(dir)
818+
819+
dir = dir.casefold()
820+
users = set()
821+
found_user = False
822+
for line in out.strip().splitlines():
823+
acl = None
824+
# First line of result includes our directory
825+
if line.startswith(dir):
826+
acl = line.removeprefix(dir).strip()
827+
elif line and line[:1].isspace():
828+
acl = line.strip()
829+
if acl:
830+
users.add(acl.partition(":")[0])
831+
832+
self.assertNotIn(r"BUILTIN\Users".casefold(), users)
833+
806834
def test_collision_with_existing_file(self):
807835
# mkdtemp tries another name when a file with
808836
# the chosen name already exists
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
:func:`os.mkdir` on Windows now accepts *mode* of ``0o700`` to restrict
2+
the new directory to the current user. This fixes :cve:`2024-4030`
3+
affecting :func:`tempfile.mkdtemp` in scenarios where the base temporary
4+
directory is more permissive than the default.

Modules/posixmodule.c

Lines changed: 169 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@
3232
# include <winioctl.h>
3333
# include <lmcons.h> // UNLEN
3434
# include "osdefs.h" // SEP
35+
# include <aclapi.h> // SetEntriesInAcl
36+
# include <sddl.h> // SDDL_REVISION_1
3537
# if defined(MS_WINDOWS_DESKTOP) || defined(MS_WINDOWS_SYSTEM)
3638
# define HAVE_SYMLINK
3739
# endif /* MS_WINDOWS_DESKTOP | MS_WINDOWS_SYSTEM */
@@ -5313,6 +5315,146 @@ os__path_normpath_impl(PyObject *module, PyObject *path)
53135315
return result;
53145316
}
53155317

5318+
#ifdef MS_WINDOWS
5319+
5320+
/* We centralise SECURITY_ATTRIBUTE initialization based around
5321+
templates that will probably mostly match common POSIX mode settings.
5322+
The _Py_SECURITY_ATTRIBUTE_DATA structure contains temporary data, as
5323+
a constructed SECURITY_ATTRIBUTE structure typically refers to memory
5324+
that has to be alive while it's being used.
5325+
5326+
Typical use will look like:
5327+
SECURITY_ATTRIBUTES *pSecAttr = NULL;
5328+
struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
5329+
int error, error2;
5330+
5331+
Py_BEGIN_ALLOW_THREADS
5332+
switch (mode) {
5333+
case 0x1C0: // 0o700
5334+
error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
5335+
break;
5336+
...
5337+
default:
5338+
error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
5339+
break;
5340+
}
5341+
5342+
if (!error) {
5343+
// do operation, passing pSecAttr
5344+
}
5345+
5346+
// Unconditionally clear secAttrData.
5347+
error2 = clearSecurityAttributes(&pSecAttr, &secAttrData);
5348+
if (!error) {
5349+
error = error2;
5350+
}
5351+
Py_END_ALLOW_THREADS
5352+
5353+
if (error) {
5354+
PyErr_SetFromWindowsErr(error);
5355+
return NULL;
5356+
}
5357+
*/
5358+
5359+
struct _Py_SECURITY_ATTRIBUTE_DATA {
5360+
SECURITY_ATTRIBUTES securityAttributes;
5361+
PACL acl;
5362+
SECURITY_DESCRIPTOR sd;
5363+
EXPLICIT_ACCESS_W ea[4];
5364+
char sid[64];
5365+
};
5366+
5367+
static int
5368+
initializeDefaultSecurityAttributes(
5369+
PSECURITY_ATTRIBUTES *securityAttributes,
5370+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
5371+
) {
5372+
assert(securityAttributes);
5373+
assert(data);
5374+
*securityAttributes = NULL;
5375+
memset(data, 0, sizeof(*data));
5376+
return 0;
5377+
}
5378+
5379+
static int
5380+
initializeMkdir700SecurityAttributes(
5381+
PSECURITY_ATTRIBUTES *securityAttributes,
5382+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
5383+
) {
5384+
assert(securityAttributes);
5385+
assert(data);
5386+
*securityAttributes = NULL;
5387+
memset(data, 0, sizeof(*data));
5388+
5389+
if (!InitializeSecurityDescriptor(&data->sd, SECURITY_DESCRIPTOR_REVISION)
5390+
|| !SetSecurityDescriptorGroup(&data->sd, NULL, TRUE)) {
5391+
return GetLastError();
5392+
}
5393+
5394+
int use_alias = 0;
5395+
DWORD cbSid = sizeof(data->sid);
5396+
if (!CreateWellKnownSid(WinCreatorOwnerRightsSid, NULL, (PSID)data->sid, &cbSid)) {
5397+
use_alias = 1;
5398+
}
5399+
5400+
data->securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
5401+
data->ea[0].grfAccessPermissions = GENERIC_ALL;
5402+
data->ea[0].grfAccessMode = SET_ACCESS;
5403+
data->ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
5404+
if (use_alias) {
5405+
data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
5406+
data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
5407+
data->ea[0].Trustee.ptstrName = L"CURRENT_USER";
5408+
} else {
5409+
data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
5410+
data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
5411+
data->ea[0].Trustee.ptstrName = (LPWCH)(SID*)data->sid;
5412+
}
5413+
5414+
data->ea[1].grfAccessPermissions = GENERIC_ALL;
5415+
data->ea[1].grfAccessMode = SET_ACCESS;
5416+
data->ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
5417+
data->ea[1].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
5418+
data->ea[1].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
5419+
data->ea[1].Trustee.ptstrName = L"SYSTEM";
5420+
5421+
data->ea[2].grfAccessPermissions = GENERIC_ALL;
5422+
data->ea[2].grfAccessMode = SET_ACCESS;
5423+
data->ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
5424+
data->ea[2].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
5425+
data->ea[2].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
5426+
data->ea[2].Trustee.ptstrName = L"ADMINISTRATORS";
5427+
5428+
int r = SetEntriesInAclW(3, data->ea, NULL, &data->acl);
5429+
if (r) {
5430+
return r;
5431+
}
5432+
if (!SetSecurityDescriptorDacl(&data->sd, TRUE, data->acl, FALSE)) {
5433+
return GetLastError();
5434+
}
5435+
data->securityAttributes.lpSecurityDescriptor = &data->sd;
5436+
*securityAttributes = &data->securityAttributes;
5437+
return 0;
5438+
}
5439+
5440+
static int
5441+
clearSecurityAttributes(
5442+
PSECURITY_ATTRIBUTES *securityAttributes,
5443+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
5444+
) {
5445+
assert(securityAttributes);
5446+
assert(data);
5447+
*securityAttributes = NULL;
5448+
if (data->acl) {
5449+
if (LocalFree((void *)data->acl)) {
5450+
return GetLastError();
5451+
}
5452+
}
5453+
return 0;
5454+
}
5455+
5456+
#endif
5457+
53165458
/*[clinic input]
53175459
os.mkdir
53185460
@@ -5342,6 +5484,12 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
53425484
/*[clinic end generated code: output=a70446903abe821f input=a61722e1576fab03]*/
53435485
{
53445486
int result;
5487+
#ifdef MS_WINDOWS
5488+
int error = 0;
5489+
int pathError = 0;
5490+
SECURITY_ATTRIBUTES *pSecAttr = NULL;
5491+
struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
5492+
#endif
53455493
#ifdef HAVE_MKDIRAT
53465494
int mkdirat_unavailable = 0;
53475495
#endif
@@ -5353,11 +5501,30 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
53535501

53545502
#ifdef MS_WINDOWS
53555503
Py_BEGIN_ALLOW_THREADS
5356-
result = CreateDirectoryW(path->wide, NULL);
5504+
switch (mode) {
5505+
case 0x1C0: // 0o700
5506+
error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
5507+
break;
5508+
default:
5509+
error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
5510+
break;
5511+
}
5512+
if (!error) {
5513+
result = CreateDirectoryW(path->wide, pSecAttr);
5514+
error = clearSecurityAttributes(&pSecAttr, &secAttrData);
5515+
} else {
5516+
// Ignore error from "clear" - we have a more interesting one already
5517+
clearSecurityAttributes(&pSecAttr, &secAttrData);
5518+
}
53575519
Py_END_ALLOW_THREADS
53585520

5359-
if (!result)
5521+
if (error) {
5522+
PyErr_SetFromWindowsErr(error);
5523+
return NULL;
5524+
}
5525+
if (!result) {
53605526
return path_error(path);
5527+
}
53615528
#else
53625529
Py_BEGIN_ALLOW_THREADS
53635530
#if HAVE_MKDIRAT

0 commit comments

Comments
 (0)