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 )