Developer-Dashboard
view release on metacpan or search on metacpan
lib/Developer/Dashboard/Web/App.pm view on Meta::CPAN
# Constructs the browser-facing dashboard web application.
# Input: auth, pages, sessions, config, and optional actions/resolver objects.
# Output: Developer::Dashboard::Web::App object.
sub new {
my ( $class, %args ) = @_;
my $auth = $args{auth} || die 'Missing auth store';
my $pages = $args{pages} || die 'Missing page store';
my $sessions = $args{sessions} || die 'Missing session store';
my $runtime = $args{runtime};
if ( !$runtime ) {
$runtime = Developer::Dashboard::PageRuntime->new(
paths => ref($pages) eq 'Developer::Dashboard::PageStore' ? $pages->{paths} : undef,
);
}
return bless {
actions => $args{actions},
auth => $auth,
config => $args{config},
pages => $pages,
prompt => $args{prompt},
runtime => $runtime,
resolver => $args{resolver},
sessions => $sessions,
}, $class;
}
# DESTROY()
# Closes the web app's optional saved-nav filesystem watcher during teardown.
# Input: app object supplied by Perl destruction.
# Output: no value; close failures are emitted as visible warnings.
sub DESTROY {
my ($self) = @_;
my $watcher = $self->{_saved_nav_items_watcher};
return if !ref($watcher) || !$watcher->can('close');
return if $watcher->close;
my $error = $watcher->can('last_error') ? $watcher->last_error : undef;
warn "Saved nav snapshot watcher close failed: "
. ( defined $error && $error ne '' ? $error : 'unknown close failure' ) . "\n";
return;
}
# HTTP methods that can change server state. The Origin/Referer half of the
# cross-site request forgery defense applies to exactly these, because a
# browser only guarantees an Origin header on a cross-site state-changing
# request. The fetch-metadata half below carries no such limit and covers GET
# too, which is what the code-executing saved-Ajax route needs.
my %STATE_CHANGING_METHODS = map { $_ => 1 } qw(POST PUT DELETE PATCH);
# Fetch-metadata values that mean the request was not issued by this
# dashboard's own pages. The browser sets Sec-Fetch-Site itself and it is a
# forbidden header name, so page script can neither forge nor suppress it.
# `same-origin` is the dashboard's own page and `none` is a user-initiated load
# (typed URL, bookmark, browser start-up), so both stay outside the check.
my %FOREIGN_FETCH_SITES = map { $_ => 1 } qw(cross-site same-site);
# _csrf_rejection_response(%args)
# Cross-site request forgery choke point for every request. Browsers
# unconditionally attach an Origin header to cross-site state-changing
# requests (and legacy flows carry a Referer), so a foreign Origin/Referer
# identifies an attack regardless of trust tier: the loopback-admin shortcut,
# an ambient helper session cookie, and even valid machine credentials must all
# refuse to act on it. Requests carrying neither header are allowed because
# non-browser machine clients (curl, registered x-dd-api-key consumers) send
# neither, while a hostile page cannot suppress the Origin header on a
# cross-site state-changing request. A GET needs the separate fetch-metadata
# check, which runs first and on every method.
# Input: normalized request method and headers (host, origin, referer,
# sec-fetch-site).
# Output: 403 empty-body response array reference when the browser context is
# foreign, otherwise undef and the request proceeds to tier dispatch.
sub _csrf_rejection_response {
my ( $self, %args ) = @_;
# Fetch metadata is checked on every method, not just the state-changing
# ones: a saved-Ajax handler is an operator-written script that the
# `/ajax/<file>` route runs from a plain GET, so GET is not a safe method
# here. Origin cannot defend that route â browsers omit Origin on
# same-origin GETs, and a hostile page can drop its Referer with a referrer
# policy â but it cannot touch Sec-Fetch-Site.
return $self->_csrf_forbidden_response if $self->_fetch_site_is_foreign(%args);
my $method = uc( $args{method} || 'GET' );
return undef if !$STATE_CHANGING_METHODS{$method};
my $headers = $args{headers} || {};
my $source = _browser_source_value($headers);
return undef if $source eq '';
return $self->_csrf_forbidden_response
if !$self->_request_source_is_same_site( source => $source, headers => $headers );
return undef;
}
# _fetch_site_is_foreign(%args)
# Reports whether the browser itself labelled this request as coming from
# another site. A request carrying no Sec-Fetch-Site is never foreign, which
# keeps non-browser clients (curl, registered x-dd-api-key consumers) and
# browsers too old to send fetch metadata working exactly as before; a hostile
# page cannot reach that shape, because every browser that can be scripted into
# making the request also sends the header. A cross-site or same-site label is
# foreign unless the accompanying Origin/Referer names this dashboard or a
# trusted local alias, which keeps the localhost/127.0.0.1 pair â one host to
# the loopback trust model, two sites to the browser â serving normally.
# Input: normalized request headers (sec-fetch-site, origin, referer, host).
# Output: boolean true when the request comes from a foreign browser context.
sub _fetch_site_is_foreign {
my ( $self, %args ) = @_;
my $headers = $args{headers} || {};
my $raw_site = $headers->{'sec-fetch-site'};
return 0 if !defined $raw_site || ref($raw_site);
return 0 if $raw_site eq 'same-origin' || $raw_site eq 'none';
my $site = lc $raw_site;
$site =~ s/^\s+//;
$site =~ s/\s+$//;
return 0 if !$FOREIGN_FETCH_SITES{$site};
my $source = _browser_source_value($headers);
return 1 if $source eq '';
return $self->_request_source_is_same_site( source => $source, headers => $headers ) ? 0 : 1;
}
# _browser_source_value($headers)
# Picks the header that names the browser context a request came from. Origin
# wins when present and Referer is the legacy fallback; reference-valued header
lib/Developer/Dashboard/Web/App.pm view on Meta::CPAN
elsif ( $filename =~ /\.svg$/i ) {
return 'image/svg+xml';
}
elsif ( $filename =~ /\.png$/i ) {
return 'image/png';
}
elsif ( $filename =~ /\.jpe?g$/i ) {
return 'image/jpeg';
}
elsif ( $filename =~ /\.gif$/i ) {
return 'image/gif';
}
elsif ( $filename =~ /\.webp$/i ) {
return 'image/webp';
}
elsif ( $filename =~ /\.ico$/i ) {
return 'image/x-icon';
}
else {
return 'application/octet-stream';
}
}
1;
__END__
=encoding UTF-8
=head1 NAME
Developer::Dashboard::Web::App - local web application for Developer Dashboard
=head1 SYNOPSIS
my $app = Developer::Dashboard::Web::App->new(
auth => $auth,
pages => $pages,
sessions => $sessions,
);
=head1 DESCRIPTION
This module handles the browser-facing dashboard routes, helper login flow,
page rendering modes, and page/action execution endpoints. It also provides
static file serving for JavaScript, CSS, and other assets from the public
directory structure (~/.developer-dashboard/dashboard/public/{js,css,others}).
The browser tab icon at C</favicon.ico> is served from the same layered
C<others> roots with a bundled fallback, and resolves before the authorization
gate because browsers request it implicitly on every page load.
Cross-site request forgery defense: every state-changing request (POST, PUT,
DELETE, PATCH) passes one origin check before any trust-tier dispatch. When
the request carries an C<Origin> header (or, absent that, a C<Referer>), its
authority must equal the request's own C<Host> header or name a trusted local
alias â a numeric loopback literal, the localhost hostname family, or a
configured C<web.ssl_subject_alt_names> entry, the same alias semantics the
loopback-admin trust check applies. Anything else, including the opaque
C<Origin: null>, is refused with an empty 403 on every tier: the
loopback-admin shortcut, helper sessions, and the machine API tier alike,
because browsers attach ambient credentials and loopback reachability to
cross-site requests automatically. Requests with neither header keep working,
since non-browser machine clients send neither while browsers always attach
C<Origin> to cross-site state-changing requests. The check also holds behind
the SSL front-proxy, which forwards TLS bytes unmodified, so the backend
compares against the browser's own C<Host> header.
That origin check cannot defend a C<GET>, because browsers omit C<Origin> on
same-origin GETs and a hostile page can drop its C<Referer> with a referrer
policy â yet C</ajax/E<lt>fileE<gt>> runs an operator-written saved handler as
a child process from a plain GET, on a tier that needs no cookie. So the same
choke point additionally refuses, on B<every> method, any request the browser
labelled C<Sec-Fetch-Site: cross-site> or C<same-site>, unless the
accompanying C<Origin>/C<Referer> names this dashboard or a trusted local
alias (the C<localhost>/C<127.0.0.1> pair is one host to the trust model and
two sites to the browser). C<Sec-Fetch-Site> is set by the browser itself and
is a forbidden header name, so page script can neither forge nor suppress it.
Requests carrying no fetch metadata are unaffected, which keeps machine
clients and browsers too old to send it working unchanged.
=head1 METHODS
=head2 new, handle
Construct and dispatch the local web application. C<handle> establishes
request-local path-identity, runtime-layer, installed-skill, web-settings,
merged-config, and Template Toolkit service scopes. Nested page/nav rendering
can reuse one Template Toolkit service for the same ordered include roots
during that request; runtime-layer directory snapshots and path identities are
discarded on return. The preferred host-address candidate list is retained on
the worker for five seconds and is refreshed after expiry.
Input: constructor dependencies for C<new>; normalized path, query, method,
headers, body, and remote address for C<handle>.
Output: C<new> returns a Web::App object; C<handle> returns a response array
reference containing status, content type, body, and optional response headers.
=head2 legacy_app_response
Loads a saved C</app/E<lt>idE<gt>> bookmark. Structured bookmark documents
render normally; raw local route bookmarks are forwarded internally with
bookmark and request parameters merged, including legacy
C<E<lt>nameE<gt>.selected.pos> array selection. Raw HTTP(S) URL bookmarks
return a C<302> redirect with the request query appended. Unsupported URL
schemes and malformed targets are rejected.
Input: saved bookmark id and normalized request metadata.
Output: response array reference of status, content type, body, and optional
headers.
=head2 _machine_ip
Returns the preferred machine IPv4 address for browser chrome, preferring VPN
interfaces before other global addresses. A worker-local candidate list is
reused for five seconds, including an empty result, after which host interface
discovery runs again so network changes become visible.
Input: none.
Output: preferred IPv4 address string, or C<undef> when no address is available.
=head2 _serve_static_file($type, $filename)
( run in 1.198 second using v1.01-cache-2.11-cpan-3f962061493 )