Developer-Dashboard

 view release on metacpan or  search on metacpan

t/126-attribute-escaping.t  view on Meta::CPAN

}

# ---------------------------------------------------------------------------
# 4. ATDD: the exploitable chain end to end over the real Dancer route table.
#    When DD-421 landed, a loopback client was auto-admin with no CSRF check,
#    so a page the operator visits could auto-submit this POST from a foreign
#    origin and the escaping alone had to keep the response inert. DD-422 then
#    added the Origin/Referer defense, so today the same drive-by submission
#    must die at the front door: an empty 403 before any save happens.
# ---------------------------------------------------------------------------
{
    my $psgi_app = Developer::Dashboard::Web::DancerApp->build_psgi_app( app => $app );

    my $separator   = ':' . ( '-' x 80 ) . ':';
    my $instruction = join "\n",
      'TITLE: Drive By',
      $separator,
      "BOOKMARK: $PAYLOAD",
      $separator,
      'HTML: <p>drive by</p>';

    Local::PSGITest::test_psgi $psgi_app, sub {
        my ($cb) = @_;

        my $save = $cb->(
            POST 'http://127.0.0.1/',
            Origin  => 'http://evil.example',
            Referer => 'http://evil.example/trap.html',
            Content => [ instruction => $instruction, mode => 'edit' ],
        );
        is( $save->code, 403, 'the foreign-origin drive-by save is rejected outright since DD-422' );
        is(
            $save->content,
            q{},
            'the rejection body is empty, so nothing built from the submitted bookmark id can render',
        );

        my $render = $cb->( GET 'http://127.0.0.1/app/chrome-probe' );
        is( $render->code, 200, 'an ordinary saved page still renders after the escaping change' );
    };
}

# ---------------------------------------------------------------------------
# 5. Regression guard: no module under lib/ may interpolate a bare variable
#    into a quoted HTML attribute. Every such value must pass through an
#    escaper first, so the whole class stays closed instead of just the three
#    sites this ticket found.
# ---------------------------------------------------------------------------
{
    my @offenders;
    find(
        {
            no_chdir => 1,
            wanted   => sub {
                return if $File::Find::name !~ /\.pm\z/;
                open my $fh, '<', $File::Find::name
                  or die "Unable to read $File::Find::name: $!";
                my $line_number = 0;
                while ( my $line = <$fh> ) {
                    $line_number++;
                    next if $line !~ /(?:href|src|action|data-[a-z-]+)="\$[A-Za-z_][A-Za-z0-9_]*"/;
                    my $rel = File::Spec->abs2rel( $File::Find::name, $repo_root );
                    push @offenders, "$rel:$line_number";
                }
                close $fh or die "Unable to close $File::Find::name: $!";
            },
        },
        File::Spec->catdir( $repo_root, 'lib' ),
    );
    is_deeply( \@offenders, [], 'no lib/ module interpolates an unescaped variable into a quoted HTML attribute' )
      or diag( join "\n", @offenders );
}

chdir $repo_root or die "Unable to chdir back to $repo_root: $!";

done_testing();

__END__

=pod

=head1 NAME

t/126-attribute-escaping.t - page-derived values stay inert inside HTML attributes

=head1 PURPOSE

This test is the injection contract for the web layer's HTML attributes. A
saved bookmark id is attacker-influenced data: it comes from the C<BOOKMARK:>
directive of a submitted instruction, and the page store only rejects
traversal components, so quotes and angle brackets reach the route builders
intact. Every route string the web chrome and the bookmark editor interpolate
into a quoted attribute must therefore be escaped, and no module under
F<lib/> may introduce a new raw attribute interpolation.

=head1 WHY IT EXISTS

DD-421 found that the shared top chrome emitted the play, source, and share
URLs raw, and that the editor substituted the form action raw. A bookmark id
containing a double quote closed the attribute and injected a live tag into
both the rendered page and the editor. The chain was exploitable because, at
the time, the web layer had no CSRF or origin check and a loopback client is
admin with no cookie: a page the operator visited could auto-submit the save
request, and the response document renders at the dashboard origin, where
injected script runs with admin trust and can reach every endpoint. DD-422
has since closed the submission channel itself with the Origin/Referer
defense, so the end-to-end section now proves the drive-by save dies as an
empty 403 while the escaping keeps protecting every rendered attribute. The
plain-text escaper leaves quotes alone by design, so the attribute sites
needed their own escaper rather than the text one.

=head1 WHEN TO USE

Use this file when changing the shared page chrome, the bookmark editor
skeleton, the saved-page route builders, or any generator that places a
route, id, or host value inside an HTML attribute.

=head1 HOW TO USE

Run C<prove -lv t/126-attribute-escaping.t> while iterating on web-layer
markup, then keep it green under C<prove -lr t>.



( run in 1.060 second using v1.01-cache-2.11-cpan-14f38c9f855 )