Developer-Dashboard

 view release on metacpan or  search on metacpan

t/130-csrf-origin-defense.t  view on Meta::CPAN

        path        => '/',
        method      => 'POST',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        cookie      => $cookie,
        origin      => 'http://evil.example',
    );
    is( $code, 403, 'foreign-Origin POST is rejected for an authenticated helper session' );
    is( $body, '', 'the helper-tier rejection carries an empty body' );

    ($code) = request(
        path        => '/',
        method      => 'POST',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        cookie      => $cookie,
        referer     => 'https://evil.example/trap.html',
    );
    is( $code, 403, 'foreign-Referer-only POST is rejected for a helper session' );

    ($code) = request(
        path        => '/',
        method      => 'POST',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        cookie      => $cookie,
        origin      => 'https://helper.example:7890',
    );
    is( $code, $control_code, 'a same-origin POST keeps working for a helper session' );

    # SSL front-proxy mode: the proxy passes TLS bytes straight through, so
    # the backend sees the browser's own Host header and the same-origin
    # comparison must keep succeeding while a foreign Origin still fails.
    {
        local $ENV{DEVELOPER_DASHBOARD_SSL_PROXIED} = 1;
        my ($proxied_code) = request(
            path        => '/login',
            method      => 'POST',
            body        => 'username=helper&password=helper-pass-123',
            remote_addr => '127.0.0.1',
            host        => $HELPER_HOST,
            origin      => 'https://helper.example:7890',
        );
        is( $proxied_code, 302, 'a same-origin login POST through the SSL front-proxy still succeeds' );

        my ($proxied_foreign_code) = request(
            path        => '/login',
            method      => 'POST',
            body        => 'username=helper&password=helper-pass-123',
            remote_addr => '127.0.0.1',
            host        => $HELPER_HOST,
            origin      => 'http://evil.example',
        );
        is( $proxied_foreign_code, 403, 'a foreign-Origin login POST through the SSL front-proxy is rejected' );
    }
}

# ---------------------------------------------------------------------------
# 3. Ajax x-dd-api-key machine tier. Machine clients send no Origin/Referer
#    and must keep working; a browser-carried foreign Origin must be rejected
#    even when the request presents valid API credentials.
# ---------------------------------------------------------------------------
{
    wfile(
        File::Spec->catfile( $paths->dashboards_root, 'legacy-ajax' ),
        "TITLE: LA\n:--------------------------------------------------------------------------------:\nBOOKMARK: legacy-ajax\n:--------------------------------------------------------------------------------:\nHTML: <script>var configs={};</script>\...
        0644,
    );
    request( path => '/app/legacy-ajax', remote_addr => '127.0.0.1', host => $ADMIN_HOST );

    my $api_json = sprintf '{"machine":{"secret":"%s","ajax":["/ajax/demo.json"]}}', sha256_hex('sekret');
    wfile( File::Spec->catfile( $paths->config_root, 'api.json' ), $api_json, 0644 );
    my $api_config = Developer::Dashboard::Config->new( files => $files, paths => $paths );
    my $api_app    = build_app( config => $api_config );

    my ($machine_code) = request(
        app         => $api_app,
        path        => '/ajax/demo.json',
        method      => 'POST',
        query       => 'type=json',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        api_key     => 'machine',
        api_secret  => 'sekret',
    );
    is( $machine_code, 200, 'a headerless machine POST with valid api credentials still works' );

    my ( $code, $body ) = request(
        app         => $api_app,
        path        => '/ajax/demo.json',
        method      => 'POST',
        query       => 'type=json',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        api_key     => 'machine',
        api_secret  => 'sekret',
        origin      => 'http://evil.example',
    );
    is( $code, 403, 'foreign-Origin POST is rejected on the api tier even with valid credentials' );
    is( $body, '', 'the api-tier cross-site rejection body is empty, not the api-forbidden JSON' );

    ($code) = request(
        app         => $api_app,
        path        => '/ajax/demo.json',
        method      => 'POST',
        query       => 'type=json',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        api_key     => 'machine',
        api_secret  => 'sekret',
        referer     => 'http://evil.example/trap.html',
    );
    is( $code, 403, 'foreign-Referer-only POST is rejected on the api tier too' );

    ($code) = request(
        app         => $api_app,
        path        => '/ajax/demo.json',
        method      => 'POST',
        query       => 'type=json',
        remote_addr => '203.0.113.7',
        host        => $HELPER_HOST,
        api_key     => 'machine',
        api_secret  => 'sekret',
        origin      => 'http://helper.example:7890',
    );
    is( $code, 200, 'a same-origin POST with valid api credentials still works' );
}

# ---------------------------------------------------------------------------
# 4. Configured SAN aliases are permitted origins. web.ssl_subject_alt_names
#    entries are the operator's declared alternate names for this dashboard,
#    so a POST whose Origin names one of them is same-site, mirroring the
#    loopback-alias semantics the trust tier already applies to Host.
# ---------------------------------------------------------------------------
{
    $config->save_global_web_settings( ssl_subject_alt_names => ['mybox.local'] );
    my $alias_config = Developer::Dashboard::Config->new( files => $files, paths => $paths );
    my $alias_app    = build_app( config => $alias_config );

    my ($code) = request(
        app         => $alias_app,
        path        => '/',
        method      => 'POST',
        remote_addr => '127.0.0.1',
        host        => $ADMIN_HOST,
        origin      => 'https://mybox.local:7890',
    );
    is( $code, $admin_control_code, 'an Origin naming a configured SAN alias is accepted' );

    ($code) = request(
        app         => $alias_app,
        path        => '/',
        method      => 'POST',
        remote_addr => '127.0.0.1',
        host        => $ADMIN_HOST,
        origin      => 'https://otherbox.local:7890',
    );
    is( $code, 403, 'an Origin naming an unlisted host is still foreign' );
}

# ---------------------------------------------------------------------------
# 5. Dancer2 route layer. The PSGI adapter must forward Origin and Referer to
#    the backend, and every state-changing entrypoint it exposes — the
#    authorized routes and the POST /login route it calls without
#    authorization — must apply the same rejection.
# ---------------------------------------------------------------------------
{
    my $psgi_app = Developer::Dashboard::Web::DancerApp->build_psgi_app( app => $app );
    Local::PSGITest::test_psgi $psgi_app, sub {
        my ($cb) = @_;

        my $foreign = $cb->(
            POST 'http://127.0.0.1:7890/',
            Origin  => 'http://evil.example',
            Content => [ x => 1 ],
        );
        is( $foreign->code, 403, 'Dancer layer rejects a foreign-Origin POST on an authorized route' );
        is( $foreign->content, '', 'Dancer-layer rejection body is empty' );

        my $foreign_referer = $cb->(
            POST 'http://127.0.0.1:7890/',
            Referer => 'http://evil.example/trap.html',
            Content => [ x => 1 ],
        );
        is( $foreign_referer->code, 403, 'Dancer layer rejects a foreign-Referer-only POST' );

t/130-csrf-origin-defense.t  view on Meta::CPAN

    }

    ok( !$auth->host_is_local_alias( host => undef ), 'an undefined host is not a local alias' );
    ok( !$auth->host_is_local_alias( host => '   ' ), 'a blank host is not a local alias' );
    ok( $auth->host_is_local_alias( host => '[::1]:7890' ), 'a bracketed IPv6 loopback with port is a local alias' );
    ok( $auth->host_is_local_alias( host => 'LOCALHOST:80' ), 'localhost matching is case-insensitive and port-blind' );
    ok(
        $auth->host_is_local_alias(
            host                 => 'mybox.local:7890',
            extra_loopback_hosts => [ undef, '', 'MyBox.Local' ],
        ),
        'configured alias entries are canonicalized and blank entries are skipped',
    );
    ok(
        !$auth->host_is_local_alias( host => 'evil.example', extra_loopback_hosts => 'not-an-array' ),
        'a non-array alias list is ignored rather than dereferenced',
    );
}

# ---------------------------------------------------------------------------
# 7. Source scan: the active defense lives in the web layer, so the audit
#    greps for the control keep finding it.
# ---------------------------------------------------------------------------
{
    my $read = sub {
        my ($relative) = @_;
        my $path = File::Spec->catfile( $repo_root, split m{/}, $relative );
        open my $fh, '<', $path or die "Unable to read $path: $!";
        local $/;
        my $source = <$fh>;
        close $fh or die "Unable to close $path: $!";
        return $source;
    };
    like( $read->('lib/Developer/Dashboard/Web/App.pm'), qr/csrf/i, 'the web app source names the CSRF defense' );
    like( $read->('lib/Developer/Dashboard/Web/App.pm'), qr/referer/i, 'the web app source covers the Referer fallback' );
    like( $read->('lib/Developer/Dashboard/Web/DancerApp.pm'), qr/origin/i, 'the route adapter forwards the Origin header' );
}

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

done_testing();

__END__

=pod

=encoding UTF-8

=head1 NAME

t/130-csrf-origin-defense.t - cross-site requests cannot change dashboard state

=head1 PURPOSE

This test is the cross-site request forgery contract for the web layer. Every
state-changing request (POST, PUT, DELETE, PATCH) that carries a foreign
browser context — an Origin header naming another site, an opaque
C<Origin: null>, or, when Origin is absent, a Referer naming another site —
must be refused with an empty 403 before any tier logic runs. The check must
hold identically on the loopback-admin shortcut, on an authenticated helper
session, and on the ajax machine tier even when valid API credentials are
presented, because a browser attaches cookies and the ambient loopback trust
automatically to cross-site requests.

=head1 WHY IT EXISTS

DD-422 closed the gap DD-421 documented: the loopback-admin tier authorizes
with no cookie at all, so any web page an operator visited could fire
state-changing requests at 127.0.0.1 and they would execute with admin trust.
Browsers always attach an Origin header to cross-site state-changing
requests, so rejecting foreign origins at one choke point blocks that class
of attack. Requests with neither Origin nor Referer stay accepted because
machine clients such as curl and the registered API consumers send neither
header, and a hostile browser cannot suppress Origin on a cross-site
state-changing request.

=head1 WHEN TO USE

Use this file when changing the request authorization flow, the trust-tier
logic, the route adapter's header forwarding, the SSL front-proxy behavior,
or anything that adds a new state-changing route to the web layer.

=head1 HOW TO USE

Run C<prove -lv t/130-csrf-origin-defense.t> while iterating on the web
layer, then keep it green under C<prove -lr t>.

=head1 WHAT USES IT

Developers during TDD and the repository test suite use this file to keep
cross-site browser contexts unable to mutate dashboard state on any tier.

=head1 EXAMPLES

Example 1:

  prove -lv t/130-csrf-origin-defense.t

Example 2:

  prove -lr t

=cut



( run in 1.520 second using v1.01-cache-2.11-cpan-007c89162af )