File-SOPS
view release on metacpan or search on metacpan
t/43-alias-bomb-refused.t view on Meta::CPAN
# Depth $levels of doubling. 25 levels is 727 bytes of YAML and 2**25 leaves
# once expanded; 9 levels is the smallest one sops refuses.
sub bomb_yaml {
my ($levels) = @_;
my $y = "l0: &l0\n v: 1\n";
$y .= "l$_: &l$_\n a: *l@{[$_ - 1]}\n b: *l@{[$_ - 1]}\n"
for 1 .. $levels;
return $y;
}
my $BOMB = bomb_yaml(9); # refused, and harmless if it is not
my $BIG_BOMB = bomb_yaml(25); # the document that used to never come back
# ---------------------------------------------------------------------------
# The premise, pinned. The whole design rests on this: YAML::XS resolves an
# alias to the SAME reference rather than to a copy, so the parse returns a
# linear DAG and does NOT explode. If it ever started copying, the expansion
# would happen inside Load, no guard of ours could reach it, and the answer
# would have to be a pre-check on the raw bytes instead.
my $parsed = YAML::XS::Load($BIG_BOMB);
is(refaddr($parsed->{l1}{a}), refaddr($parsed->{l1}{b}),
'YAML::XS resolves an alias to the same reference, not to a copy');
is(refaddr($parsed->{l1}{a}), refaddr($parsed->{l0}),
'and that reference is the anchored node itself');
is(guarded(sub { File::SOPS::Format::YAML->parse($BIG_BOMB) }), 'OK',
'the parse itself still returns: the blowup is in the walks, not the load');
# ---------------------------------------------------------------------------
# Step 1: the probe. Everything below depends on the guard existing at all.
my $guard_holds = do {
my $ok = eval {
File::SOPS::_assert_expansion_bounded(YAML::XS::Load($BIG_BOMB));
1;
};
!$ok && $@ =~ /excessive aliasing/;
};
ok($guard_holds, '_assert_expansion_bounded refuses a 25-level alias bomb');
# Asserts that $code refuses. With the guard in place this is instant; with a
# call site missing it succeeds in milliseconds and fails here, because the
# fixture is sized so that expanding it is cheap.
sub refuses {
t/43-alias-bomb-refused.t view on Meta::CPAN
}
like($err, qr/excessive aliasing/, "$name refuses an alias bomb")
or diag("got: $err");
}
# ---------------------------------------------------------------------------
# The encrypt side. Used to expand 2**25 leaves and never come back.
refuses('encrypt', sub {
File::SOPS->encrypt(
data => YAML::XS::Load($BOMB),
recipients => [$public],
format => 'yaml',
);
});
# JSON has no aliases, but a caller can hand encrypt a shared structure of
# their own. Same blowup, no parser anywhere near it, one guard for both.
refuses('encrypt (format => json)', sub {
File::SOPS->encrypt(
data => YAML::XS::Load($BOMB),
recipients => [$public],
format => 'json',
);
});
refuses('encrypt (caller-built shared hash refs)', sub {
my $node = { v => 1 };
$node = { a => $node, b => $node } for 1 .. 12;
File::SOPS->encrypt(
data => { root => $node },
t/43-alias-bomb-refused.t view on Meta::CPAN
recipients => [$public],
format => 'yaml',
);
});
# The reproduction. This is the document from the ticket, and the assertion is
# that it now comes back at all.
{
my $got = guarded(sub {
File::SOPS->encrypt(
data => YAML::XS::Load($BIG_BOMB),
recipients => [$public],
format => 'yaml',
);
});
if ($got eq 'HANG') {
fail('encrypt returns on the 25-level bomb from k112');
diag("HUNG -- did not return within ${TIMEOUT}s, which is the defect.");
}
else {
like($got, qr/\ADIE .*excessive aliasing/,
'encrypt returns on the 25-level bomb from k112')
or diag("got: $got");
}
}
# The message quotes sops's own wording and says how far out of proportion the
# document is, because "too big" without a number is not actionable.
my $msg = do {
eval {
File::SOPS->encrypt(
data => YAML::XS::Load($BOMB),
recipients => [$public],
format => 'yaml',
);
};
my $e = $@ // ''; $e =~ s/\s+/ /g; $e;
};
like($msg, qr/\Qyaml: document contains excessive aliasing\E/,
'the refusal quotes the wording sops refuses with');
like($msg, qr/expands to \d+ values from the \d+ it holds/,
'and names both counts');
t/43-alias-bomb-refused.t view on Meta::CPAN
format => 'yaml',
);
sub bomb_document {
my ($body) = @_;
my $doc = $sane;
$doc =~ s/^plain: .*\n/$body/m or die "splice failed";
return $doc;
}
my $bomb_doc = bomb_document($BOMB);
isnt($bomb_doc, $sane, 'built an encrypted document carrying an alias bomb');
refuses('decrypt', sub {
File::SOPS->decrypt(encrypted => $bomb_doc, identities => [$secret]);
});
# ignore_mac suppresses verification, not the document's shape. Before the
# guard this was the path that expanded furthest: it skips _verify_mac and
# walks the whole tree in _decrypt_tree regardless.
refuses('decrypt (ignore_mac => 1)', sub {
t/43-alias-bomb-refused.t view on Meta::CPAN
my ($other_public, $other_secret) = Crypt::Age->generate_keypair();
refuses('decrypt (with an identity that cannot open the file)', sub {
File::SOPS->decrypt(
encrypted => $bomb_doc,
identities => [$other_secret],
);
});
# The read-side reproduction, at the size that used to hang.
{
my $big_doc = bomb_document($BIG_BOMB);
my $got = guarded(sub {
File::SOPS->decrypt(
encrypted => $big_doc,
identities => [$secret],
ignore_mac => 1,
);
});
if ($got eq 'HANG') {
fail('decrypt returns on the 25-level bomb from k112');
diag("HUNG -- did not return within ${TIMEOUT}s, which is the defect.");
t/43-alias-bomb-refused.t view on Meta::CPAN
File::SOPS->rotate(file => $enc_path, identities => [$secret]);
});
{
local $ENV{EDITOR} = 'true';
refuses('edit', sub {
File::SOPS->edit(file => $enc_path, identities => [$secret]);
});
}
my $plain_path = write_file(in_dir('bomb.yaml'), $BOMB);
my $out_path = in_dir('bomb.out.yaml');
refuses('encrypt_file', sub {
File::SOPS->encrypt_file(
input => $plain_path,
output => $out_path,
recipients => [$public],
);
});
ok(!-e $out_path, 'encrypt_file wrote no output on the refusal');
my $in_place_path = write_file(in_dir('in-place.yaml'), $BOMB);
refuses('encrypt_in_place', sub {
File::SOPS->encrypt_in_place(file => $in_place_path, recipients => [$public]);
});
is(do { open my $fh, '<', $in_place_path or die; local $/; <$fh> }, $BOMB,
'encrypt_in_place left the original untouched on the refusal');
# ---------------------------------------------------------------------------
# The two guards are ordered, and the order is load-bearing: the census memo
# in _expansion_census is filled on the way OUT, so a cycle would recurse
# forever inside it. _assert_acyclic runs first, and a cyclic document has to
# keep reporting the cycle. This one runs forked, because getting it wrong is
# a hang.
{
my $got = guarded(sub {
( run in 1.363 second using v1.01-cache-2.11-cpan-8dfa8b56332 )