File-SOPS

 view release on metacpan or  search on metacpan

t/43-alias-bomb-refused.t  view on Meta::CPAN

# Both regressions were rehearsed rather than assumed. With the two call sites
# removed this file reports 20 of 39 failures in 16s; with the guard's body
# removed, 25 of 39 in 14s. Neither hangs and neither thrashes.

plan skip_all => 'this test forks to bound a regression, and fork is not '
    . 'available here'
    unless $Config{d_fork};

my $TIMEOUT = 5;

# Runs $code in a child under alarm. Returns 'OK', "DIE <message>", or 'HANG'.
sub guarded {
    my ($code) = @_;

    pipe(my $read, my $write) or die "pipe: $!";

    my $pid = fork();
    die "fork: $!" unless defined $pid;

    if (!$pid) {
        close $read;
        local $SIG{ALRM} = sub {
            print {$write} "HANG\n";
            close $write;
            # _exit, never exit: exit would run Test::More's END block in the
            # child and emit a second, duplicate TAP plan.
            POSIX::_exit(0);
        };
        alarm $TIMEOUT;
        my $ok  = eval { $code->(); 1 };
        my $err = $ok ? '' : $@;
        alarm 0;
        $err =~ s/\s+/ /g;
        print {$write} ($ok ? "OK\n" : "DIE $err\n");
        close $write;
        POSIX::_exit(0);
    }

    close $write;
    my $line = <$read>;
    close $read;
    waitpid $pid, 0;

    $line = 'HANG' unless defined $line;
    chomp $line;
    return $line;
}

my ($public, $secret) = Crypt::Age->generate_keypair();

# 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 {
    my ($name, $code) = @_;
    unless ($guard_holds) {
        fail("$name refuses an alias bomb");
        diag('SKIPPED the call: _assert_expansion_bounded is gone, so this '
            . 'would expand 2**N values. See k112 and docs/adr/0027.');
        return;
    }
    my $ok = eval { $code->(); 1 };
    my $err = $ok ? '' : $@;
    $err =~ s/\s+/ /g;
    if ($ok) {
        fail("$name refuses an alias bomb");
        diag('RETURNED instead of refusing -- the guard exists but this entry '
            . 'point no longer calls it. This is k112 back again.');
        return;
    }
    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 },
        recipients => [$public],
        format     => 'yaml',
    );
});

refuses('encrypt (caller-built shared array refs)', sub {
    my $node = [1];
    $node = [ $node, $node ] for 1 .. 12;
    File::SOPS->encrypt(
        data       => { root => $node },
        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');
like($msg, qr/Reusing an anchor is ordinary and is not this/,
    'and says what is NOT this, so a reused anchor is not read as the defect');

# ---------------------------------------------------------------------------
# The decrypt side. A document sops WROTE cannot carry this, because sops
# refuses to write it -- but a hand-written one can, and that is the document
# every read path used to expand.

my $sane = File::SOPS->encrypt(
    data       => { plain => 'x' },
    recipients => [$public],
    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 {
    File::SOPS->decrypt(
        encrypted  => $bomb_doc,
        identities => [$secret],
        ignore_mac => 1,
    );
});

# The refusal comes AHEAD of the key, which is the order sops answers in:
# measured, `sops -d` on such a file with no identity available reports the
# aliasing, not a failure to get the data key.
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.");
    }
    else {
        like($got, qr/\ADIE .*excessive aliasing/,
            'decrypt returns on the 25-level bomb from k112')
            or diag("got: $got");
    }
}

# ---------------------------------------------------------------------------
# The file entry points, all of which funnel through encrypt or decrypt.

my $dir = tempdir(CLEANUP => 1);
sub in_dir { File::Spec->catfile($dir, $_[0]) }

sub write_file {
    my ($path, $content) = @_;
    open my $fh, '>', $path or die "open $path: $!";
    binmode $fh;
    print {$fh} $content;
    close $fh or die "close $path: $!";
    return $path;
}

my $enc_path = write_file(in_dir('bomb.enc.yaml'), $bomb_doc);

refuses('decrypt_file', sub {
    File::SOPS->decrypt_file(
        input      => $enc_path,
        output     => in_dir('out.yaml'),
        identities => [$secret],
    );
});

refuses('extract', sub {
    File::SOPS->extract(
        file       => $enc_path,
        path       => '["l0"]["v"]',
        identities => [$secret],
    );
});

refuses('rotate', sub {
    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 {
        File::SOPS->encrypt(
            data       => YAML::XS::Load("root: &a\n  b: *a\n"),
            recipients => [$public],
            format     => 'yaml',
        );
    });
    like($got, qr/\ADIE .*contains itself/,
        'a cyclic document still reports the cycle, not the aliasing')
        or diag("got: $got");
}

# ---------------------------------------------------------------------------
# The guard must not OVER-refuse, and the threshold is not ours: it is
# go-yaml's, reproduced. Bisected against sops 3.13.3, which decides this.
#
# The pair that matters most is the second and the third below. sops ACCEPTS a
# 206,104-node expansion and REFUSES a 8,146-node one, because what it budgets
# is how far the expansion exceeds the document that produced it -- a RATIO,
# not a count. Anyone who "simplifies" this into a cap on expanded nodes
# refuses files sops accepts, and these assertions are what says so.

sub accepts_or_refuses {
    my ($name, $yaml, $want) = @_;
    # A 265-deep DAG legitimately recurses past perl's threshold of 100, and
    # every walk in this distribution does the same on such a document. The
    # warning is muted rather than avoided, so the fixture can sit at the
    # depth sops's boundary actually sits at.
    local $SIG{__WARN__} = sub {
        warn $_[0] unless $_[0] =~ /\ADeep recursion/;
    };
    my $tree = YAML::XS::Load($yaml);
    my $ok = eval { File::SOPS::_assert_expansion_bounded($tree); 1 };
    is($ok ? 'ACCEPT' : 'REFUSE', $want, $name)
        or diag($ok ? 'accepted' : "refused: $@");
}

# Depth $depth, each level referencing the previous $width times, over a base
# anchor of $pairs pairs.
sub chain_yaml {
    my ($depth, $width, $pairs) = @_;
    my $y = "l0: &l0\n";
    $y .= "  k$_: $_\n" for 1 .. $pairs;
    for my $i (1 .. $depth) {
        $y .= "l$i: &l$i\n";
        $y .= "  r$_: *l@{[$i - 1]}\n" for 1 .. $width;
    }
    return $y;
}

# One anchor of $pairs pairs, referenced $refs times.



( run in 1.637 second using v1.01-cache-2.11-cpan-8dfa8b56332 )