Database-BI
view release on metacpan or search on metacpan
lib/Database/BI.pm view on Meta::CPAN
# of it to the CDNs, the tile servers and any site a reader follows a link to.
# OpenStreetMap's tile policy asks pages for a Referer, so it is not none.
Readonly my $REFERRER_POLICY => 'strict-origin-when-cross-origin';
# Permissions-Policy: every powerful browser feature off, for this site and
# any frame it holds, but writing to the clipboard, which the Copy link
# buttons do. Only names Chromium knows: it logs an error for any other.
Readonly my @PERMISSIONS_DENIED => qw(
accelerometer autoplay browsing-topics camera clipboard-read display-capture
encrypted-media fullscreen gamepad geolocation gyroscope hid
identity-credentials-get idle-detection local-fonts magnetometer microphone
midi otp-credentials payment picture-in-picture publickey-credentials-create
publickey-credentials-get screen-wake-lock serial storage-access usb
window-management xr-spatial-tracking
);
Readonly my $DEFAULT_PERMISSIONS_POLICY =>
join(', ', (map { "$_=()" } @PERMISSIONS_DENIED), 'clipboard-write=(self)');
# Cross-Origin-Opener-Policy for a page: a window another site opened, or a
# window a page opens on another site, gets no handle on the page. No page
# opens a window or reads window.opener; links out are rel="noopener" anyway.
Readonly my $DEFAULT_COOP => 'same-origin';
Readonly my %COOP_VALUES => map { $_ => 1 } qw(same-origin same-origin-allow-popups noopener-allow-popups unsafe-none);
lib/Database/BI.pm view on Meta::CPAN
C<POST /export> also refuses a folder or file whose name starts with a dot
(F<~/.config>, F<.vscode/settings.json>), since those hold other programs'
settings. The folder list in the Export panel leaves them out already.
=head2 Which addresses it may fetch
C</import> and a C<url:> data source make the server fetch a URL, so a page
could point it at what only the server can reach: a service on this machine, a
database on the office network, or a cloud's metadata service on
C<169.254.169.254>, which hands out its credentials. So the server refuses a
URL that leads to this machine, a private or shared network
(C<10.0.0.0/8>, C<172.16.0.0/12>, C<192.168.0.0/16>, C<100.64.0.0/10>,
C<fc00::/7>), link-local addresses, multicast or a reserved range.
It is what the name I<resolves to> that counts, not how the URL reads:
C<http://localtest.me/> and C<http://127.0.0.1.nip.io/> are names whose DNS
answers C<127.0.0.1>, and C<http://2130706433/> or C<http://0x7f000001/> are
C<127.0.0.1> written another way; all are refused. An address is looked up
once, checked, and that address is the one connected to, so a name that answers
differently the second time (DNS rebinding) gains nothing. Each redirect is
lib/Database/BI.pm view on Meta::CPAN
my $jtype = $c->param('jtype') // '';
$c->stash(
view_join_type => ($jtype =~ /\A(?:left|inner|outer)\z/ ? $jtype : ''),
view_filters => [ grep { length } @{ $c->every_param('f') } ],
view_combines => [ grep { /\A(?:path|table|url|redis|mariadb):/i } @{ $c->every_param('c') } ],
view_dedup => ($c->param('d') ? 1 : 0),
# lk=/nolk= turn a stored lookup on or off for this view, as name=value
# pairs so the macro can write them back in the order they came.
view_lookups => [ map { my $k = $_; map { { name => $k, value => $_ } } grep { length } @{ $c->every_param($k) } } qw(lk nolk) ],
# Names only: the rest of an entry is credentials.
mariadb_server_names => [ sort keys %{ $c->app->config('mariadb_servers') // {} } ],
);
});
# Every page names the tutorial chapter that helps with it. The layout
# turns this into a "Help: ..." link beside the main Tutorial link, so a
# reader on the pie chart screen lands on the pie chart chapter. It lives
# here rather than in every action: a new action is one entry in the action
# map in Database::BI::Tutorial, and t/tutorial.t checks it is there.
$self->hook(before_render => sub {
lib/Database/BI/Ask.pm view on Meta::CPAN
label => 'sales',
columns => $typed,
ai_config => { provider => 'anthropic', api_key => $key, model => 'claude-haiku-5-5' },
);
# $result = { spec => {...}, url => '/pie?l=...&cat=region', valid => 1 }
=head1 DESCRIPTION
Translates a plain-English question into an analysis spec by calling
the Anthropic Messages API. The model receives only column names and types
-- never raw row data, file paths, or credentials.
The returned spec is validated before being returned to the caller: every
column name in the C<encode> section must appear in the supplied column list,
and the C<kind> must be a supported chart type. An invalid spec is returned
as C<{ error =E<gt> 'ask_invalid_spec:...' }> and never executed.
The feature is entirely absent (returns C<ask_unavailable>) when no
C<api_key> is supplied, keeping the app functional with no AI configured.
=head1 FUNCTIONS
t/cgi_security.t view on Meta::CPAN
subtest '/import -- http://127.0.0.1/ is rejected (SSRF loopback)' => sub {
# Exploit mechanism: bare loopback IPv4 bypasses hostname-based blocklists.
# Proof: $host =~ /\A127\./ check in _is_safe_url() catches all of 127/8.
$t->get_ok('/import?url=' . url_escape('http://127.0.0.1/'))
->status_is(200)
->content_like(qr/private or reserved/i,
'127.0.0.1 is rejected as a loopback address');
};
subtest '/import -- http://169.254.169.254/ is rejected (SSRF cloud metadata)' => sub {
# Exploit mechanism: AWS/GCP/Azure metadata endpoint returns IAM credentials
# and instance metadata; no authentication is required from the instance.
# Proof: 169.254/16 is in the link-local block checked by _is_safe_url().
$t->get_ok('/import?url=' . url_escape('http://169.254.169.254/latest/meta-data/'))
->status_is(200)
->content_like(qr/private or reserved/i,
'169.254.169.254 (cloud metadata) is rejected');
};
# ---------------------------------------------------------------------------
# Attack vector 6: Join left-spec injection
t/function.t view on Meta::CPAN
Test::Mockingbird::unmock('DBI::connect');
};
subtest 'DataSource::_mariadb_dsn -- what connects, without connecting' => sub {
my $fn = \&Database::BI::Model::DataSource::_mariadb_dsn;
my $dbh = Stub::DBH->new;
$with_dbi->($dbh, sub {
my ($calls) = @_;
my @got = $fn->($DSX{server}, { database => $DSX{database}, username => 'u', password => $DSX{password} });
like $got[0], $DSX{dsn_re}, 'a DSN built only from checked parts, with a time-out';
is_deeply [ @got[1, 2] ], [ 'u', $DSX{password} ], 'and the credentials';
is scalar @{$calls}, 0, 'nothing is connected: Database::Join::Catalog and Database::Abstraction do that';
});
};
# Purpose: the driver is optional; without one the reader is told what to
# install rather than seeing a Perl error.
subtest 'DataSource::_mariadb_dsn -- no driver' => sub {
my $fn = \&Database::BI::Model::DataSource::_mariadb_dsn;
local @INC = (sub { my (undef, $file) = @_; die "hidden\n" if $file =~ m{\ADBD/(?:MariaDB|mysql)\.pm\z}; return }, @INC);
local $INC{'DBD/MariaDB.pm'};
t/mariadb_live.t view on Meta::CPAN
eval { require Database::Join };
plan skip_all => 'Database::Join required' if $@;
}
use DBI;
use Readonly;
use Scalar::Util qw(refaddr);
use Test::Mojo;
# ---------------------------------------------------------------------------
# Parse MARIADB_SERVER into DSN + credentials
# ---------------------------------------------------------------------------
my ($DSN, $DB_USER, $DB_PASS) = _parse_server($ENV{MARIADB_SERVER});
sub _parse_server {
my ($raw) = @_;
return ($raw, undef, undef) if $raw =~ /\Adbi:/i;
$raw = "mariadb://$raw" unless $raw =~ m{\A[a-z]+://}i;
t/mariadb_live.t view on Meta::CPAN
my $rows = _orders()->selectall_arrayref({ entry => q{row1' OR '1'='1} });
is_deeply($rows // [], [], 'a criterion is bound, not written into the SQL');
};
subtest 'L20: a table that is not there is an error naming it' => sub {
plan tests => 3;
throws_ok { _maria_source("${ORDERS}_missing") } qr/\Q${ORDERS}_missing\E/, 'opening it names the table';
$t->get_ok("/open?path=$O" . '_missing')->content_like(qr/\Q${ORDERS}_missing\E/, 'and so does the page');
};
subtest 'L21: a refused login is a page that keeps the credentials to itself' => sub {
plan tests => 10;
my $wrong = 'not-the-password-' . $$;
local $t->app->config->{mariadb_servers}{live}{password} = $wrong;
for my $url ("/open?path=$O", '/tables?l=mariadb:live') {
$t->get_ok($url)->status_is(200);
my $body = $t->tx->res->body;
unlike($body, qr/\Q$wrong\E/, "$url: the password is not on the page");
unlike($body, qr/\Qdbi:\E(?:MariaDB|mysql)/, 'nor is the DSN');
unlike($body, qr/\.pm line \d+/, 'nor a source location');
}
t/mariadb_live.t view on Meta::CPAN
is(scalar @{ _export_rows("l=mariadb:live/$shape&f=" . url_escape('Amount Due:gt:40')) }, 5, 'a filter reads the rest');
is_deeply(_maria_source($view)->columns, ['alpha'], 'a view opens, having no key');
$t->get_ok("/open?path=mariadb:live/$view&per_page=3")->status_is(200)
->content_like(qr{<td[^>]*>n\d+</td>}, 'and shows its rows');
$setup_dbh->do("DROP VIEW `$view`");
$setup_dbh->do("DROP TABLE `$shape`");
};
subtest 'L25: a canvas keeps the spec, and the spec holds no credentials' => sub {
plan tests => 5;
$t->post_ok('/api/analyses' => json => { name => 'Maria', datasets => [ { spec => $O } ] })->status_is(201);
my $id = $t->tx->res->json('/id');
$t->get_ok("/api/analyses/$id")->status_is(200);
unlike($t->tx->res->body, qr/\Q$SERVER_HOST\E/, 'the host is not saved');
};
t/transaction.t view on Meta::CPAN
# Phase 6: non-standard extension (.log) -- _sniff_data_ext renames to .csv.
# The stub serves the same CSV body for .log; DataSource must detect the
# comma-separated content and open it correctly without a "Can't find a
# file" error.
my $log_path = '/../mockhost/tmp/remote_sales.log';
$t->get_ok('/open?path=' . url_escape($log_path))
->status_is(200, 'Phase 6: non-standard .log extension opens via content sniffing')
->content_like(qr/Widget/, 'Phase 6: first data row visible after sniff rename')
->content_like(qr/product/i, 'Phase 6: column header present after sniff rename');
# Phase 7: user@hostname syntax passes credentials to Net::SFTP::Foreign.
# DataSource splits "testuser@mockhost" into user="testuser", host="mockhost"
# and forwards user => $user to Net::SFTP::Foreign->new.
$captured_sftp_user = undef;
my $user_path = '/../testuser@mockhost/tmp/remote_sales.csv';
$t->get_ok('/open?path=' . url_escape($user_path))
->status_is(200, 'Phase 7: user@hostname syntax opens correctly')
->content_like(qr/Widget/, 'Phase 7: data visible with user@hostname path');
is($captured_sftp_user, 'testuser',
'Phase 7: username extracted from user@hostname and forwarded to Net::SFTP::Foreign');
( run in 1.317 second using v1.01-cache-2.11-cpan-3f962061493 )