Skip to content
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

Load index from validated msix for unpackaged context #2139

Merged
merged 14 commits into from
May 16, 2022

Conversation

yao-msft
Copy link
Contributor

@yao-msft yao-msft commented May 5, 2022

Validation: Added new tests and fixed existing tests. Also did manual verification that source opening works as expected.

Microsoft Reviewers: Open in CodeFlow

@yao-msft yao-msft requested a review from a team as a code owner May 5, 2022 03:19
@@ -214,4 +215,74 @@ namespace TestCommon
{
AppInstaller::Settings::SetUserSettingsOverride(nullptr);
}

bool InstallCertFromSignedPackage(const std::filesystem::path& package)
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a fan of requiring the tests to run as admin.
Also not a fan of the tests changing the security profile of the machine they are run on.

Could we possibly test with a package signed by something already trusted? #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I made the tests to skip if not running with admin. But I cannot find a way to "mark" the tests as skipped. So I just output an info message.

ComPtr<IStream> otherStream;
THROW_IF_FAILED(SHCreateStreamOnFileEx(otherManifest.c_str(),
STGM_READ | STGM_SHARE_DENY_WRITE | STGM_FAILIFTHERE, 0, FALSE, nullptr, &otherStream));
MsixInfo other{ otherPackage.u8string() };
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we just have a MsixInfo(const std::filesystem::path&) constructor? #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just tried and since the other constructor is std::string_view, compiler gives ambiguous overloaded function call unless we update all the callers

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I asked about this and found out that this is a reasonable way to do it:

    template<typename T, std::enable_if_t<std::is_same_v<T, std::filesystem::path>, int> = 0>
    MsixInfo(const T& path) { ConstructFromPath(path); }

Where ConstructFromPath can then be implemented in the .cpp.

MsixInfo msixInfo{ msixPath.u8string() };
auto signature = msixInfo.GetSignature();
THROW_HR_IF(E_UNEXPECTED, signature.size() <= P7xFileIdSize);
signature.erase(signature.begin(), signature.begin() + P7xFileIdSize);
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetSignature(true) to skip the first 4 bytes if they match the file id? erase here is expensive and isn't actually verifying that the bytes match the id. #Resolved


bool ValidateMsixTrustInfo(const std::filesystem::path& msixPath, bool verifyMicrosoftOrigin)
{
bool result = true;
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
bool result = true;
bool result = false;

Bugs should not result in returning a 👍 verification. #Resolved

WINTRUST_DATA trustData = { 0 };
trustData.cbStruct = sizeof(WINTRUST_DATA);
trustData.dwUIChoice = WTD_UI_NONE;
trustData.fdwRevocationChecks = WTD_REVOKE_NONE;
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Shouldn't we be doing revocation checks? #Resolved


if (!std::filesystem::exists(packageLocation))
{
AICLI_LOG(Repo, Info, << "Data not found at " << packageLocation);
THROW_HR(APPINSTALLER_CLI_ERROR_SOURCE_DATA_MISSING);
}

SQLiteIndex index = SQLiteIndex::Open(packageLocation.u8string(), SQLiteIndex::OpenDisposition::Read);
THROW_HR_IF(APPINSTALLER_CLI_ERROR_SOURCE_DATA_INTEGRITY_FAILURE, !Msix::ValidateMsixTrustInfo(packageLocation, WI_IsFlagSet(m_details.TrustLevel, SourceTrustLevel::StoreOrigin)));
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This appears to be vulnerable to a time of check vs time of use attack; nothing is preventing the package from being replaced between the check here and opening the file later.

You need the TempSQLiteIndexFile (or another helper) that can lock the file from being modified, then do the validation and extraction. #Resolved

{
namespace
{
static constexpr std::string_view s_PreIndexedPackageSourceFactory_IndexFilePath = "Public\\index.db"sv;
Copy link
Member

@JohnMcPMS JohnMcPMS May 9, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This shouldn't be defined in a second location. #Resolved

@@ -57,7 +58,7 @@ namespace AppInstaller::Repository::Microsoft
};

// Opens an existing index database.
static SQLiteIndex Open(const std::string& filePath, OpenDisposition disposition);
static SQLiteIndex Open(const std::string& filePath, OpenDisposition disposition, TempSQLiteIndexFile&& tempIndexFileHandle = {});
Copy link
Member

@JohnMcPMS JohnMcPMS May 10, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't love this being passed in, but I understand the need. Can we change the type to WriteLockedFile or something more generic? That could be used for both the package and index files (with a create bool that controls both open and remove on destruction functionality).

Then also just move the "extract the index file" part out of the type as it isn't really necessary to be included. #Resolved

@ghost ghost added Needs-Author-Feedback Issue needs attention from issue or PR author and removed Needs-Author-Feedback Issue needs attention from issue or PR author labels May 10, 2022
@github-actions
Copy link

github-actions bot commented May 11, 2022

@check-spelling-bot Report

Unrecognized words, please review:

  • exising
  • WHOLECHAIN
Previously acknowledged words that are now absent activatable amd Archs dsc enr FWW Globals hackathon lww mytool OSVERSION Packagedx parametermap symlink Uninitialize WDAG whatif wsb
To accept these unrecognized words as correct (and remove the previously acknowledged and now absent words), run the following commands

... in a clone of the [email protected]:yao-msft/winget-cli.git repository
on the loadmsixindex branch:

update_files() {
perl -e '
my @expect_files=qw('".github/actions/spelling/expect.txt"');
@ARGV=@expect_files;
my @stale=qw('"$patch_remove"');
my $re=join "|", @stale;
my $suffix=".".time();
my $previous="";
sub maybe_unlink { unlink($_[0]) if $_[0]; }
while (<>) {
if ($ARGV ne $old_argv) { maybe_unlink($previous); $previous="$ARGV$suffix"; rename($ARGV, $previous); open(ARGV_OUT, ">$ARGV"); select(ARGV_OUT); $old_argv = $ARGV; }
next if /^(?:$re)(?:(?:\r|\n)*$| .*)/; print;
}; maybe_unlink($previous);'
perl -e '
my $new_expect_file=".github/actions/spelling/expect.txt";
use File::Path qw(make_path);
use File::Basename qw(dirname);
make_path (dirname($new_expect_file));
open FILE, q{<}, $new_expect_file; chomp(my @words = <FILE>); close FILE;
my @add=qw('"$patch_add"');
my %items; @items{@words} = @words x (1); @items{@add} = @add x (1);
@words = sort {lc($a)."-".$a cmp lc($b)."-".$b} keys %items;
open FILE, q{>}, $new_expect_file; for my $word (@words) { print FILE "$word\n" if $word =~ /\w/; };
close FILE;
system("git", "add", $new_expect_file);
'
}

comment_json=$(mktemp)
curl -L -s -S \
  --header "Content-Type: application/json" \
  "https://api.github.com/repos/microsoft/winget-cli/issues/comments/1123174846" > "$comment_json"
comment_body=$(mktemp)
jq -r .body < "$comment_json" > $comment_body
rm $comment_json

patch_remove=$(perl -ne 'next unless s{^</summary>(.*)</details>$}{$1}; print' < "$comment_body")
  

patch_add=$(perl -e '$/=undef;
$_=<>;
s{<details>.*}{}s;
s{^#.*}{};
s{\n##.*}{};
s{(?:^|\n)\s*\*}{}g;
s{\s+}{ }g;
print' < "$comment_body")
  
update_files
rm $comment_body
git add -u

{
if (!Runtime::IsRunningAsAdmin())
{
INFO("Test requires admin privilege. Skipped.");
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
INFO("Test requires admin privilege. Skipped.");
WARN("Test requires admin privilege. Skipped.");

Not sure how this behaves instead, maybe it is more obvious? #Resolved

ManagedFile(ManagedFile&&) = default;
ManagedFile& operator=(ManagedFile&&) = default;

HANDLE GetFileHandle() { return m_fileHandle.get(); }
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
HANDLE GetFileHandle() { return m_fileHandle.get(); }
HANDLE GetFileHandle() const { return m_fileHandle.get(); }
``` #Resolved

ManagedFile& operator=(ManagedFile&&) = default;

HANDLE GetFileHandle() { return m_fileHandle.get(); }
const std::filesystem::path& GetFilePath() { return m_filePath; }
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
const std::filesystem::path& GetFilePath() { return m_filePath; }
const std::filesystem::path& GetFilePath() const { return m_filePath; }
``` #Resolved

// If we got bytes, just accept them and keep going.
LOG_IF_FAILED(hr);

THROW_LAST_ERROR_IF(INVALID_SET_FILE_POINTER == SetFilePointer(target, 0, nullptr, FILE_END));
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we need to set this every time through the loop? #Resolved

else
{
// If given a size, and we have read it all, quit
if (expectedSize && totalBytesRead == expectedSize)
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't appear to be doing anything to prevent writing more than expectedSize. Is that intentional? #Resolved

@@ -335,6 +556,18 @@ namespace AppInstaller::Msix
THROW_IF_FAILED(signatureStream->Stat(&stat, STATFLAG_NONAME));
THROW_HR_IF(E_UNEXPECTED, stat.cbSize.HighPart != 0); // Signature size should be small
signatureSize = stat.cbSize.LowPart;
THROW_HR_IF(E_UNEXPECTED, signatureSize <= P7xFileIdSize);

if (getRawSignature)
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getRawSignature being true, but the code then modifying the signature, doesn't make sense to me. Maybe this was supposed to be if (!getRawSignature)? Or should the name be changed to skipFileID? #Resolved

THROW_IF_FAILED(CoCreateGuid(&guid));
WCHAR tempFileName[256];
THROW_HR_IF(E_UNEXPECTED, StringFromGUID2(guid, tempFileName, ARRAYSIZE(tempFileName)) == 0);
auto tempIndexFilePath = Runtime::GetPathTo(Runtime::PathName::Temp);
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would either create a new function GetNewTempFilePath or GetPathTo(PathName::NewTempFile), but both would return the same type of path you are constructing here. This is definitely reusable. #Resolved

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

by looking at GetPathTo() code, it's more of returning a directory caller can work on. I'll create GetNewTempFilePath() then

// If we already have a manifest, use it to determine if we need to update or not.
if (!packageInfo.IsNewerThan(manifestPath))
// If we already have a trusted index package, use it to determine if we need to update or not.
if (Msix::ValidateMsixTrustInfo(packagePath, WI_IsFlagSet(details.TrustLevel, SourceTrustLevel::StoreOrigin)) &&
Copy link
Member

@JohnMcPMS JohnMcPMS May 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You need another

auto indexPackageLock = Utility::ManagedFile::OpenWriteLockedFile(packagePath, 0);

to prevent a TOCTOU here as well (between validation and the IsNewerThan).

It might even be worth making a helper type like:

struct PersistedIndexPackage
{
PersistedIndexPackage(path);
bool ValidateMsixTrustInfo(bool checkMSOrigin);

private:
ManagedFile m_fileLock;
}

Then remove the free function ValidateMsixTrustInfo. That way you must lock the file to validate it, and hopefully that translates into the appropriate lifetime of the lock in the calling code. #Resolved

@ghost ghost added Needs-Author-Feedback Issue needs attention from issue or PR author and removed Needs-Author-Feedback Issue needs attention from issue or PR author labels May 11, 2022
@yao-msft
Copy link
Contributor Author

/azp run

@azure-pipelines
Copy link

Azure Pipelines successfully started running 1 pipeline(s).


bool ValidateMsixTrustInfo(bool checkMicrosoftOrigin) const
{
return Msix::ValidateMsixTrustInfo(m_file.GetFilePath(), checkMicrosoftOrigin);
Copy link
Member

@JohnMcPMS JohnMcPMS May 13, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reason to move to this model was that the only way to call ValidateMsixTrustInfo would be to create a PersistedIndexPackage. If you leave the free function ValidateMsixTrustInfo around, someone can still use it without the file lock. #Resolved

@yao-msft yao-msft merged commit 0b1142f into microsoft:master May 16, 2022
@yao-msft yao-msft deleted the loadmsixindex branch May 16, 2022 23:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants