-
Notifications
You must be signed in to change notification settings - Fork 8.5k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add a file for storing elevated-only state #11222
Conversation
This comment has been minimized.
This comment has been minimized.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM. @lhecker may want to take a look
[this, settingsPath](wil::FolderChangeEvent, PCWSTR fileModified) { | ||
static const std::filesystem::path statePath{ std::wstring_view{ ApplicationState::SharedInstance().FilePath() } }; | ||
static const std::filesystem::path elevatedStatePath{ std::wstring_view{ ElevatedState::SharedInstance().FilePath() } }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I personally prefer the previous code, which didn't call .filename()
repeatedly in the closure. The .filename()
function could possibly be a very heavy operation after all (for instance by checking the file system in future STL versions)...
However we can shorten the existing code by adding some helper lambdas:
static constexpr auto intoPath = [](const std::wstring_view& path) -> std::filesystem::path {
return path;
};
static constexpr auto intoFilename = [](const std::wstring_view& path) -> std::filesystem::path {
return intoPath(path).filename();
};
Afterwards we can shorten the capture group down to a single character:
void AppLogic::_RegisterSettingsChange()
{
// ... lambdas here ...
const auto settingsPath = intoPath(CascadiaSettings::SettingsPath());
const auto settingsBasename = settingsPath.filename();
const auto stateBasename = intoFilename(ApplicationState::SharedInstance().FilePath());
const auto elevatedStateBasename = intoFilename(ElevatedState::SharedInstance().FilePath());
_reader.create(
// ...
[=](wil::FolderChangeEvent, PCWSTR fileModified) {
// ...
});
}
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
See, I tried just capturing them all, but the compiler gave me some error about having too much stuff being captured or something. I forget the error exactly. It was unhappy with me capturing the fourth thing. Hence moving the stuff inside the lambda itself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
wistd::function
only allows holding 12 pointers and a std::filesystem::path
is 4 pointers large.
I wonder what's so bad about std::function
that it wasn't used here. I guess the reason is that they wanted something that's noexcept
constructible... (but why?) Certainly very annoying now though.
In that case however you could still write this (outside of the closure - it's automatically captured):
// wistd::function only stores closures as large as 12 pointers and each path is 4 pointers large.
// --> They need to be static, otherwise the code doesn't compile.
static const auto settingsBasename = settingsPath.filename();
static const auto stateBasename = intoFilename(ApplicationState::SharedInstance().FilePath());
static const auto elevatedStateBasename = intoFilename(ElevatedState::SharedInstance().FilePath());
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BTW I wonder if we're doing it all wrong either way (if we're thinking outside of the scope of this PR)...
Shouldn't the the caller - in this case the AppLogic
class - decide which file to load and not CascadiaSettings
? I guess LoadAll
should have a path string argument. That way we could safely hardcode the correct filenames into AppLogic.cpp
.
if (!hadExpectedPermissions) | ||
{ | ||
// delete the file. It's been compromised. | ||
LOG_LAST_ERROR_IF(!DeleteFile(path.c_str())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we should delete a user's files.
Refusing to load it should be enough, shouldn't it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a good point for discussion - I'm not sure if we just ignore reading the file, then try writing it with the correct permissions set, if it'll overwrite the permissions. I don't think it will. Either way, we're going to end up blowing away the contents currently in the file with whatever the first state we add to the file is.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please keep the deletion. Even if it's not strictly necessary at this point in time, better to harden further when possible. Deleting the file removes any chance of anything bad happening because if the deletion fails, then the user has got much bigger problems with their Windows host than simply what's happening with Terminal.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah -- i'm also of the mind that state
is not the user's property. It just happens to live in the user's house. They can change it, own it, do whatever they want... but if Terminal ignores it or deletes it: Oh well.
* [x] delete all state, open terminal elevated - generated profiles go to the correct place, approved commandlines go to the elevated one. * [x] delete some dynamic profiles, open the terminal - they don't resurrect in either IL * [ ] GAH saving a window layout unelevated, then trying to save one elevated will blow away the unelevated one
This fixes the issue I had in the last commit. It's a little weird, but gets rid of the muckiness of layering. Things that are local to one elevation level won't pollute the other, and we don't need to worry about layering or where they came from. Just write shared state to `state.json`, and window state to `elevated-state`/`user-state`
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
This comment has been minimized.
// If a property is Shared, then it'll be stored in `state.json`, and used | ||
// in both elevated and unelevated instances of the Terminal. If a property | ||
// is marked Local, then it will have separate values for elevated and | ||
// unelevated instances. | ||
enum FileSource : int | ||
{ | ||
Shared = 0x1, | ||
Local = 0x2 | ||
}; | ||
DEFINE_ENUM_FLAG_OPERATORS(FileSource); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Any interest in doing this? @lhecker this something you would require?
if (!hadExpectedPermissions) | ||
{ | ||
// delete the file. It's been compromised. | ||
LOG_LAST_ERROR_IF(!DeleteFile(path.c_str())); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah -- i'm also of the mind that state
is not the user's property. It just happens to live in the user's house. They can change it, own it, do whatever they want... but if Terminal ignores it or deletes it: Oh well.
wil::unique_hfile file{ CreateFileW(path.c_str(), | ||
GENERIC_WRITE, | ||
FILE_SHARE_READ | FILE_SHARE_DELETE, | ||
elevatedOnly ? &sa : nullptr, | ||
CREATE_ALWAYS, | ||
FILE_ATTRIBUTE_NORMAL, | ||
nullptr) }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The existing security is retained when the file is overwritten with CREATE_ALWAYS
disposition. That's okay only if the file is owned by the administrators group. To check this, maybe change _isOwnedByAdministrators()
to take a file handle and call GetSecurityInfo()
instead of GetNamedSecurityInfoW()
. This entails also updating ReadUTF8File()
to move the check after CreateFileW()
. (Related: I think ReadUTF8File
should be able to do the file create outside of the loop, and just reset the file pointer on each pass.)
In WriteUTF8File()
, if elevatedOnly
is true and the file isn't owned by the administrators group, or opening fails with ERROR_ACCESS_DENIED
, I suggest doing an atomic replace. Create a new file beside the existing file, with a unique name and CREATE_NEW
disposition. Then rename the new file to the target name.
An alternative to an atomic replace would be to rewrite the security of the existing file, but that's more complicated. The existing permissions may not allow opening with WRITE_OWNER
and WRITE_DAC
access, or even GENERIC_WRITE
access. SeRestorePrivilege would have to be enabled for the current thread, and the open would have to use FILE_FLAG_BACKUP_SEMANTICS
. The security info to set would be OWNER_SECURITY_INFORMATION | LABEL_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION | UNPROTECTED_DACL_SECURITY_INFORMATION
. Setting "unprotected" DACL information allows using an empty DACL (not NULL
; just empty), since the permissions will be inherited from the parent directory. This avoids having to create a custom DACL for the current user.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This problem with WriteUTF8File()
should be avoided, else the write is just a waste of time since ReadUTF8File()
will ignore it. Personally, I'd wrap CreateFileW()
with the addition of the elevatedOnly
parameter. Replace an existing file with a new empty file if the existing file isn't owned by the administrators. An administrator should be free to change anything about the file security except for the owner. There's no way to know whether to trust the file if it's not owned by the administrators group.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've read your comments just now, but I'm not sure I understand them entirely...
First of all: Is there a security issue with the current code?
The way I understand this, we don't check the security info during writing which can cause us to create a file with the wrong security info despite elevatedOnly
being true, right? But is that an issue if we delete that file during ReadUTF8File
anyways? I personally don't mind this being a "waste of time", because this would be a situation that can only be created artificially, for instance by a user creating the file themselves... As such it's unlikely to occur in practice, isn't it?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is there a security issue with the current code?
I don't think so because the user always has read access anyway. There's just not any point in writing data that will be thrown away.
this would be a situation that can only be created artificially, for instance by a user
creating the file themselves
Yes, for example it would occur if the user edits the file using an editor that deletes the original before saving. (Overwriting a file just deletes its data, alternate data streams, and extended attributes, but not its security descriptor.) This will succeed in a standard security context, since the file is in a directory that grants the user FILE_DELETE_CHILD
access. The new file will be owned by the user instead of the administrators group and inherit its permissions from the parent directory.
// Open the file _first_, then check if it has the right | ||
// permissions. This prevents a "Time-of-check to time-of-use" | ||
// vulnerability where a malicious exe could delete the file and | ||
// replace it between us checking the permissions, and reading the | ||
// contents. We've got a handle to the file now, which means we're |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CreateFileW()
and checking the owner are expensive and don't need to be in the loop. When bytesRead != fileSize
, the loop can reset the file pointer via SetFilePointerEx(file.get(), zero, NULL, FILE_BEGIN)
and continue
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Filed- #11753
Thanks!
(We don't expect to hit this codepath that many times in rapid succession, but we should still be wary of doing it the right way.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nothing blocking really. Just a few odds and ends.
// General getters/setters | ||
winrt::hstring FilePath() const noexcept; | ||
bool IsStatePath(const winrt::hstring& filename); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it really necessary to do winrt::hstring&
? I thought it internally had some sort of reference copy magic. @lhecker do you know?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's not necessary, but if we can save an AddRef/Release, why not?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the reference in place, MSVC can properly inline this call into the generated WinRT classes.
wil::unique_hfile file{ CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr) }; | ||
wil::unique_hfile file{ CreateFileW(path.c_str(), | ||
GENERIC_READ, | ||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we weren't sharing... we wouldn't need to be concerned with someone else editing it while we're reading. Why didn't we briefly take exclusive use? Does it mess up how editors work with it, I'm guessing?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah exclusive reads/writes fail because editors hold shared locks already.
// The required security descriptor can be created easily from the | ||
// SDDL string: "S:(ML;;NW;;;HI)" | ||
// (i.e. SACL:mandatory label;;no write up;;;high integrity level) | ||
unsigned long cb; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shouldn't you check something about cb?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This parameter is actually optional and can be dropped here.
THROW_IF_WIN32_BOOL_FALSE( | ||
ConvertStringSecurityDescriptorToSecurityDescriptor(L"S:(ML;;NW;;;HI)", | ||
SDDL_REVISION_1, | ||
wil::out_param_ptr<PSECURITY_DESCRIPTOR*>(sd), |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First time I'm seeing wil::out_param_ptr
... I presume it's better somehow than like &sd or &sd.get() or something?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I believe this is a copy-paste from the wil docs and is not required here. It's basically equivalent to:
static_cast<PSECURITY_DESCRIPTOR*>(sd.put())
This is required because wil::hlocal_*
wrappers return HLOCAL
pointers for put()
.
But PSECURITY_DESCRIPTOR
is of type void*
with which the return value of .addressof()
(and put()
- HLOCAL
) is compatible with.
wil::unique_hfile file{ CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_WRITE, nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr) }; | ||
wil::unique_hfile file{ CreateFileW(path.c_str(), | ||
GENERIC_READ, | ||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why is delete OK now?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This just broadens the situations in which we can open the file. Widest sharing perms = fewer opportunities for applications to stop us from doing something useful with the user's settings.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Most editors overwrite a file when they save, which requires write sharing. Some (vim?) apparently delete an existing file first [1]. In Windows 10+, DeleteFileW()
has been updated to try a POSIX delete. If the filesystem supports it, already open files can continue accessing the original file, even though it's unlinked from the directory. (NTFS renames the deleted file to the reserved directory "\$Extend\$Deleted".) If a POSIX delete isn't supported, the file is marked as deleted, but not unlinked, and the name is inaccessible for new opens until all handles for existing opens have been closed. In either case, existing opens have to share delete access. Otherwise DeleteFileW()
will fail with a sharing violation.
[1] IMO, deleting the file is wrong. It discards all of the metadata -- not only create timestamp, attributes, extended attributes, alternate data streams, but also the security descriptor, including the owner, mandatory and discretionary permissions, integrity level, security attributes, and access auditing.
bool Utils::IsElevated() | ||
{ | ||
static bool isElevated = []() { | ||
try | ||
{ | ||
wil::unique_handle processToken{ GetCurrentProcessToken() }; | ||
const auto elevationType = wil::get_token_information<TOKEN_ELEVATION_TYPE>(processToken.get()); | ||
const auto elevationState = wil::get_token_information<TOKEN_ELEVATION>(processToken.get()); | ||
if (elevationType == TokenElevationTypeDefault && elevationState.TokenIsElevated) | ||
{ | ||
// In this case, the user has UAC entirely disabled. This is sort of | ||
// weird, we treat this like the user isn't an admin at all. There's no | ||
// separation of powers, so the things we normally want to gate on | ||
// "having special powers" doesn't apply. | ||
// | ||
// See GH#7754, GH#11096 | ||
return false; | ||
} | ||
|
||
return wil::test_token_membership(nullptr, SECURITY_NT_AUTHORITY, SECURITY_BUILTIN_DOMAIN_RID, DOMAIN_ALIAS_RID_ADMINS); | ||
} | ||
catch (...) | ||
{ | ||
LOG_CAUGHT_EXCEPTION(); | ||
return false; | ||
} | ||
}(); | ||
return isElevated; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Why here and not in til
? I thought we were moving as much as we could to til
and getting rid of old school utils.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eh. I'm 50/50 on putting things into til that are just functions. Since it doesn't have a static lib, it's "annoying" (requires the forceinline/noinline specifier for OPT:REF and OPT:ICF and COMDAT folding to work)
All concerns addressed -- I don't have the time at the moment to review so i trust michael and leonard
@msftbot make sure @DHowett-MSFT signs off on this |
Hello @DHowett! Because you've given me some instructions on how to help merge this pull request, I'll be modifying my merge approach. Here's how I understand your requirements for merging this pull request:
If this doesn't seem right to you, you can tell me to cancel these instructions and use the auto-merge policy that has been configured for this repository. Try telling me "forget everything I just told you". |
This PR needs to be merged using the github UI, not the bot's automerge feature. Automerge breaks stacked PRs. @lhecker please copy the PR body into the commit body when you're done |
// General getters/setters | ||
winrt::hstring FilePath() const noexcept; | ||
bool IsStatePath(const winrt::hstring& filename); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With the reference in place, MSVC can properly inline this call into the generated WinRT classes.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some unimportant nits.
// for why. ApplicationState::SharedInstance already caches it's | ||
// own static ref. | ||
|
||
const winrt::hstring modifiedBasename{ std::filesystem::path{ fileModified }.filename().c_str() }; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should use .native()
more liberally on std::filesystem::path
s. It returns a constant reference to the underlying wide string for free.
|
||
if (modifiedBasename == settingsBasename) | ||
{ | ||
_reloadSettings->Run(); | ||
} | ||
else if (modifiedBasename == stateBasename) | ||
else if (ApplicationState::SharedInstance().IsStatePath(modifiedBasename)) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You don't need to construct a hstring to pass it to a COM call. All std strings implicitly convert to winrt::param::hstring
.
LOG_LAST_ERROR_IF(!DeleteFile(path.c_str())); | ||
|
||
// Exit early, because obviously there's nothing to read from the deleted file. | ||
return ""; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefer return {}
over return ""
. Those aren't actually the same.
🎉 Handy links: |
Summary of the Pull Request
This creates an
elevated-state.json
that lives in%LOCALAPPDATA%
next tostate.json
, that's only writable when elevated. It doesn't use this file for anything, it just puts the framework down for use later.It's just like
ApplicationState
. We'll use it the same way.It's readable when unelevated, which is nice, but not writable. If you're dumb and try to write to the file when unelevated, it'll just silently do nothing.
If we try opening the file and find out the permissions are different, we'll blow the file away entirely. This is to prevent someone from renaming the original file (which they can do unelevated), then slapping a new file that's writable by them down in it's place.
References
state.json
#11096, but these PRs need to be broken up.PR Checklist
state.json
at all yetValidation Steps Performed
I've played with this much more in
dev/migrie/f/non-terminal-content-elevation-warning
followed by #11308, #11310