Repository navigation
Ensure ntpath.realpath() result is a final, normalized pathname. #110495
Description
Bug report
Bug description:
Note that
_readlink_deep()does nothing to ensure that the result is a final, normalized pathname. It just tries to keep reading symlinks until the target isn't a symlink or access/reading fails in a manner that's allowed. If it succeeds, the resulting pathname may include any number of symlinks, mount points, and short names._getfinalpathname_nonstrict()should instead pass both the pathname and theseenset into_readlink_deep(). If it succeeds, then add the resulting pathname to the set, and continue the loop to try to get a final, normalized pathname.Originally posted by @eryksun in #110298 (comment)
I was able to reproduce this issue. Run the following code block with Administrator privileges in Windows:
mkdir C:\testdir
mkdir C:\testdir\real
echo 1 > C:\testdir\real\file.txt
icacls C:\testdir\real\file.txt /deny *S-1-5-32-545:(S)
mklink /d C:\testdir\symlink1 C:\testdir\real
mklink /d C:\testdir\symlink2 C:\testdir\symlink1
mklink C:\testdir\symlink2\filelink.txt C:\testdir\symlink1\file.txt
python -c "import ntpath; print(ntpath.realpath('C:\\testdir\\symlink2\\filelink.txt'))"
REM Cleanup
icacls C:\testdir\real\file.txt /grant *S-1-5-32-545:(S)
rmdir C:\testdir /s /qExpected output:
C:\testdir\real\file.txt
Actual output:
C:\testdir\symlink1\file.txt
This happens because _getfinalpathname_nonstrict() returns the result of _readlink_deep() if the return value is different from the input.
Lines 680 to 686 in 92ca90b
| try: | |
| # The OS could not resolve this path fully, so we attempt | |
| # to follow the link ourselves. If we succeed, join the tail | |
| # and return. | |
| new_path = _readlink_deep(path) | |
| if new_path != path: | |
| return join(new_path, tail) if tail else new_path |
However, because _readlink_deep() does not ensure that it's result is a final, normalized pathname, ntpath.realpath() result is not ensured to be a final, normalized pathname.
I'll try to come up with a patch. @eryksun, if you have any further discussions or comments on how this should be fixed, please let me know in the comments.
CPython versions tested on:
CPython main branch
Operating systems tested on:
Windows
Linked PRs
Metadata
Metadata
Assignees
Labels
Projects
- StatusShow more project fieldsTodo
Activity
sobolevn commentedon Oct 7, 2023
CC @barneygale
zooba commentedon Oct 9, 2023
It seems like it happens because we don't try to
_getfinalpathnameon each element of the path?_readlink_deepis only attempting to resolve a link, but the result you're getting here contains a link, but is not actually a link.Personally, I'm inclined to not try too hard when the final file isn't accessible. If there's a way we can easily tell that some segment of the path is actually a link without having to test every single one, then maybe it's worth it. But if someone has constructed a symlink chain as complex as this and made the final file inaccessible, I'm okay with letting them win.
eryksun commentedon Oct 9, 2023
This should be a simple fix. Just pass the
seenset to_readlink_deepas a parameter. If it succeeds,_getfinalpathname_nonstrictadds the result to the set and continues the loop, resolving the path down to the device/share.zooba commentedon Oct 9, 2023
You mean the fix is to not return, but to replace
path, updatetailand keep looping until a_getfinalpathnamecall succeeds? Then we also need a secondseenset for cycle detection, but I think we need to store full paths in it and not the partial (parent directory) we're working with in subsequent iterations insidenonstrict.Maybe it won't matter, because if a partial path happens to collide with a full path we've already seen then we've still got a cycle? It's too late at night for me to puzzle that one out in my head.
Either way, I'm not convinced this is a simple fix. It might be a small code change, but we need to explain the logic as well, if not in code, then in one of those massive comments that ends up being longer than the entire function (and we're already pretty close to that with this function...)
eryksun commentedon Oct 10, 2023
Only a single
seenset is needed. It gets passed into_readlink_deepas a parameter. For_readlink_deep, other than changingseento be a parameter, nothing else needs to change. I neglected to mention that if_readlink_deepreturns a path that's already inseen,_getfinalpathname_nonstrict()should break out of its loop. Otherwise, add the result to the set and continue the loop. Theseenset wouldn't be used anywhere else in_getfinalpathname_nonstrict(). It's just meant to ensure that calling_readlink_deepreturns if it encounters any path that was seen by either a previous_readlink_deepcall or the current call.Merge branch 'main' into pythongh-110495-ensure-ntpath-realpath-final…