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 )