Skip to content

Commit 86da8de

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

File tree

6 files changed

+247
-2
lines changed

6 files changed

+247
-2
lines changed

Doc/library/os.rst

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

1912+
On Windows, a *mode* of ``0o700`` is specifically handled to apply access
1913+
control to the new directory such that only the current user and
1914+
administrators have access. Other values of *mode* are ignored.
1915+
19121916
This function can also support :ref:`paths relative to directory descriptors
19131917
<dir_fd>`.
19141918

@@ -1923,6 +1927,9 @@ features:
19231927
.. versionchanged:: 3.6
19241928
Accepts a :term:`path-like object`.
19251929

1930+
.. versionchanged:: 3.8.20
1931+
Windows now handles a *mode* of ``0o700``.
1932+
19261933

19271934
.. function:: makedirs(name, mode=0o777, exist_ok=False)
19281935

Doc/whatsnew/3.8.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,13 @@ treat junctions as links.
10461046

10471047
(Contributed by Steve Dower in :issue:`37834`.)
10481048

1049+
As of 3.8.20, :func:`os.mkdir` and :func:`os.makedirs` on Windows now support
1050+
passing a *mode* value of ``0o700`` to apply access control to the new
1051+
directory. This implicitly affects :func:`tempfile.mkdtemp` and is a
1052+
mitigation for :cve:`2024-4030`. Other values for *mode* continue to be
1053+
ignored.
1054+
(Contributed by Steve Dower in :gh:`118486`.)
1055+
10491056

10501057
os.path
10511058
-------
@@ -1252,6 +1259,15 @@ in a standardized and extensible format, and offers several other benefits.
12521259
(Contributed by C.A.M. Gerlach in :issue:`36268`.)
12531260

12541261

1262+
tempfile
1263+
--------
1264+
1265+
As of 3.8.20 on Windows, the default mode ``0o700`` used by
1266+
:func:`tempfile.mkdtemp` now limits access to the new directory due to
1267+
changes to :func:`os.mkdir`. This is a mitigation for :cve:`2024-4030`.
1268+
(Contributed by Steve Dower in :gh:`118486`.)
1269+
1270+
12551271
threading
12561272
---------
12571273

Lib/test/test_os.py

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

1383+
@unittest.skipUnless(os.name == 'nt', "requires Windows")
1384+
def test_win32_mkdir_700(self):
1385+
base = os_helper.TESTFN
1386+
path1 = os.path.join(os_helper.TESTFN, 'dir1')
1387+
path2 = os.path.join(os_helper.TESTFN, 'dir2')
1388+
# mode=0o700 is special-cased to override ACLs on Windows
1389+
# There's no way to know exactly how the ACLs will look, so we'll
1390+
# check that they are different from a regularly created directory.
1391+
os.mkdir(path1, mode=0o700)
1392+
os.mkdir(path2, mode=0o777)
1393+
1394+
out1 = subprocess.check_output(["icacls.exe", path1], encoding="oem")
1395+
out2 = subprocess.check_output(["icacls.exe", path2], encoding="oem")
1396+
os.rmdir(path1)
1397+
os.rmdir(path2)
1398+
out1 = out1.replace(path1, "<PATH>")
1399+
out2 = out2.replace(path2, "<PATH>")
1400+
self.assertNotEqual(out1, out2)
1401+
13831402
def tearDown(self):
13841403
path = os.path.join(support.TESTFN, 'dir1', 'dir2', 'dir3',
13851404
'dir4', 'dir5', 'dir6')

Lib/test/test_tempfile.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import contextlib
1212
import stat
1313
import weakref
14+
import subprocess
1415
from unittest import mock
1516

1617
import unittest
@@ -760,6 +761,33 @@ def test_mode(self):
760761
finally:
761762
os.rmdir(dir)
762763

764+
@unittest.skipUnless(os.name == "nt", "Only on Windows.")
765+
def test_mode_win32(self):
766+
# Use icacls.exe to extract the users with some level of access
767+
# Main thing we are testing is that the BUILTIN\Users group has
768+
# no access. The exact ACL is going to vary based on which user
769+
# is running the test.
770+
dir = self.do_create()
771+
try:
772+
out = subprocess.check_output(["icacls.exe", dir], encoding="oem").casefold()
773+
finally:
774+
os.rmdir(dir)
775+
776+
dir = dir.casefold()
777+
users = set()
778+
found_user = False
779+
for line in out.strip().splitlines():
780+
acl = None
781+
# First line of result includes our directory
782+
if line.startswith(dir):
783+
acl = line.removeprefix(dir).strip()
784+
elif line and line[:1].isspace():
785+
acl = line.strip()
786+
if acl:
787+
users.add(acl.partition(":")[0])
788+
789+
self.assertNotIn(r"BUILTIN\Users".casefold(), users)
790+
763791
def test_collision_with_existing_file(self):
764792
# mkdtemp tries another name when a file with
765793
# 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: 173 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
#include "pycore_pystate.h" /* _PyRuntime */
4141
#include "pythread.h"
4242
#include "structmember.h"
43+
44+
#ifdef MS_WINDOWS
45+
# include <aclapi.h> // SetEntriesInAcl
46+
# include <sddl.h> // SDDL_REVISION_1
47+
#endif
48+
4349
#ifndef MS_WINDOWS
4450
# include "posixmodule.h"
4551
#else
@@ -4123,6 +4129,146 @@ os__path_splitroot_impl(PyObject *module, path_t *path)
41234129
#endif /* MS_WINDOWS */
41244130

41254131

4132+
#ifdef MS_WINDOWS
4133+
4134+
/* We centralise SECURITY_ATTRIBUTE initialization based around
4135+
templates that will probably mostly match common POSIX mode settings.
4136+
The _Py_SECURITY_ATTRIBUTE_DATA structure contains temporary data, as
4137+
a constructed SECURITY_ATTRIBUTE structure typically refers to memory
4138+
that has to be alive while it's being used.
4139+
4140+
Typical use will look like:
4141+
SECURITY_ATTRIBUTES *pSecAttr = NULL;
4142+
struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
4143+
int error, error2;
4144+
4145+
Py_BEGIN_ALLOW_THREADS
4146+
switch (mode) {
4147+
case 0x1C0: // 0o700
4148+
error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
4149+
break;
4150+
...
4151+
default:
4152+
error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
4153+
break;
4154+
}
4155+
4156+
if (!error) {
4157+
// do operation, passing pSecAttr
4158+
}
4159+
4160+
// Unconditionally clear secAttrData.
4161+
error2 = clearSecurityAttributes(&pSecAttr, &secAttrData);
4162+
if (!error) {
4163+
error = error2;
4164+
}
4165+
Py_END_ALLOW_THREADS
4166+
4167+
if (error) {
4168+
PyErr_SetFromWindowsErr(error);
4169+
return NULL;
4170+
}
4171+
*/
4172+
4173+
struct _Py_SECURITY_ATTRIBUTE_DATA {
4174+
SECURITY_ATTRIBUTES securityAttributes;
4175+
PACL acl;
4176+
SECURITY_DESCRIPTOR sd;
4177+
EXPLICIT_ACCESS_W ea[4];
4178+
char sid[64];
4179+
};
4180+
4181+
static int
4182+
initializeDefaultSecurityAttributes(
4183+
PSECURITY_ATTRIBUTES *securityAttributes,
4184+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
4185+
) {
4186+
assert(securityAttributes);
4187+
assert(data);
4188+
*securityAttributes = NULL;
4189+
memset(data, 0, sizeof(*data));
4190+
return 0;
4191+
}
4192+
4193+
static int
4194+
initializeMkdir700SecurityAttributes(
4195+
PSECURITY_ATTRIBUTES *securityAttributes,
4196+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
4197+
) {
4198+
assert(securityAttributes);
4199+
assert(data);
4200+
*securityAttributes = NULL;
4201+
memset(data, 0, sizeof(*data));
4202+
4203+
if (!InitializeSecurityDescriptor(&data->sd, SECURITY_DESCRIPTOR_REVISION)
4204+
|| !SetSecurityDescriptorGroup(&data->sd, NULL, TRUE)) {
4205+
return GetLastError();
4206+
}
4207+
4208+
int use_alias = 0;
4209+
DWORD cbSid = sizeof(data->sid);
4210+
if (!CreateWellKnownSid(WinCreatorOwnerRightsSid, NULL, (PSID)data->sid, &cbSid)) {
4211+
use_alias = 1;
4212+
}
4213+
4214+
data->securityAttributes.nLength = sizeof(SECURITY_ATTRIBUTES);
4215+
data->ea[0].grfAccessPermissions = GENERIC_ALL;
4216+
data->ea[0].grfAccessMode = SET_ACCESS;
4217+
data->ea[0].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
4218+
if (use_alias) {
4219+
data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
4220+
data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
4221+
data->ea[0].Trustee.ptstrName = L"CURRENT_USER";
4222+
} else {
4223+
data->ea[0].Trustee.TrusteeForm = TRUSTEE_IS_SID;
4224+
data->ea[0].Trustee.TrusteeType = TRUSTEE_IS_WELL_KNOWN_GROUP;
4225+
data->ea[0].Trustee.ptstrName = (LPWCH)(SID*)data->sid;
4226+
}
4227+
4228+
data->ea[1].grfAccessPermissions = GENERIC_ALL;
4229+
data->ea[1].grfAccessMode = SET_ACCESS;
4230+
data->ea[1].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
4231+
data->ea[1].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
4232+
data->ea[1].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
4233+
data->ea[1].Trustee.ptstrName = L"SYSTEM";
4234+
4235+
data->ea[2].grfAccessPermissions = GENERIC_ALL;
4236+
data->ea[2].grfAccessMode = SET_ACCESS;
4237+
data->ea[2].grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT;
4238+
data->ea[2].Trustee.TrusteeForm = TRUSTEE_IS_NAME;
4239+
data->ea[2].Trustee.TrusteeType = TRUSTEE_IS_ALIAS;
4240+
data->ea[2].Trustee.ptstrName = L"ADMINISTRATORS";
4241+
4242+
int r = SetEntriesInAclW(3, data->ea, NULL, &data->acl);
4243+
if (r) {
4244+
return r;
4245+
}
4246+
if (!SetSecurityDescriptorDacl(&data->sd, TRUE, data->acl, FALSE)) {
4247+
return GetLastError();
4248+
}
4249+
data->securityAttributes.lpSecurityDescriptor = &data->sd;
4250+
*securityAttributes = &data->securityAttributes;
4251+
return 0;
4252+
}
4253+
4254+
static int
4255+
clearSecurityAttributes(
4256+
PSECURITY_ATTRIBUTES *securityAttributes,
4257+
struct _Py_SECURITY_ATTRIBUTE_DATA *data
4258+
) {
4259+
assert(securityAttributes);
4260+
assert(data);
4261+
*securityAttributes = NULL;
4262+
if (data->acl) {
4263+
if (LocalFree((void *)data->acl)) {
4264+
return GetLastError();
4265+
}
4266+
}
4267+
return 0;
4268+
}
4269+
4270+
#endif
4271+
41264272
/*[clinic input]
41274273
os.mkdir
41284274
@@ -4151,6 +4297,12 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
41514297
/*[clinic end generated code: output=a70446903abe821f input=e965f68377e9b1ce]*/
41524298
{
41534299
int result;
4300+
#ifdef MS_WINDOWS
4301+
int error = 0;
4302+
int pathError = 0;
4303+
SECURITY_ATTRIBUTES *pSecAttr = NULL;
4304+
struct _Py_SECURITY_ATTRIBUTE_DATA secAttrData;
4305+
#endif
41544306

41554307
if (PySys_Audit("os.mkdir", "Oii", path->object, mode,
41564308
dir_fd == DEFAULT_DIR_FD ? -1 : dir_fd) < 0) {
@@ -4159,11 +4311,30 @@ os_mkdir_impl(PyObject *module, path_t *path, int mode, int dir_fd)
41594311

41604312
#ifdef MS_WINDOWS
41614313
Py_BEGIN_ALLOW_THREADS
4162-
result = CreateDirectoryW(path->wide, NULL);
4314+
switch (mode) {
4315+
case 0x1C0: // 0o700
4316+
error = initializeMkdir700SecurityAttributes(&pSecAttr, &secAttrData);
4317+
break;
4318+
default:
4319+
error = initializeDefaultSecurityAttributes(&pSecAttr, &secAttrData);
4320+
break;
4321+
}
4322+
if (!error) {
4323+
result = CreateDirectoryW(path->wide, pSecAttr);
4324+
error = clearSecurityAttributes(&pSecAttr, &secAttrData);
4325+
} else {
4326+
// Ignore error from "clear" - we have a more interesting one already
4327+
clearSecurityAttributes(&pSecAttr, &secAttrData);
4328+
}
41634329
Py_END_ALLOW_THREADS
41644330

4165-
if (!result)
4331+
if (error) {
4332+
PyErr_SetFromWindowsErr(error);
4333+
return NULL;
4334+
}
4335+
if (!result) {
41664336
return path_error(path);
4337+
}
41674338
#else
41684339
Py_BEGIN_ALLOW_THREADS
41694340
#if HAVE_MKDIRAT

0 commit comments

Comments
 (0)