Database-BI

 view release on metacpan or  search on metacpan

lib/Database/BI/Controller/Dashboard.pm  view on Meta::CPAN

#   network-layer egress firewall.  This function handles the Perl-layer
#   interception for the most common patterns that operators cannot easily
#   filter at the network level: bare loopback aliases and literal private IPs
#   hard-coded by an attacker.
#
# Blocked targets:
#   localhost / 127.0.0.0/8 / 0.0.0.0 / ::1  -- loopback aliases
#   10.0.0.0/8    -- RFC 1918 private (literal IP)
#   172.16.0.0/12 -- RFC 1918 private (literal IP)
#   192.168.0.0/16-- RFC 1918 private (literal IP)
#   169.254.0.0/16-- link-local; AWS/GCP/Azure metadata endpoint (literal IP)
#   100.64.0.0/10 -- CGNAT / Tailscale shared space (literal IP)
#
# Hostname-based targets (e.g. http://internal.corp.example.com/) are allowed
# at this layer; block them with egress firewall rules instead.
sub _is_safe_url {
	my ($url) = @_;
	return 0 unless $url =~ m{\Ahttps?://([^/:?\[\]#]+)}i;
	my $host = lc $1;

	# Block well-known loopback aliases.

lib/Database/BI/Controller/Dashboard.pm  view on Meta::CPAN


B<Upload a data file by dropping it onto the page (multipart form POST):>

  curl -X POST http://localhost:3000/upload \
       -F file=@/home/user/data/sales.csv
  # Returns: {"url":"/open?path=/.../.uploads/.../sales.csv","path":"/.../.uploads/.../sales.csv"}

=head1 DESCRIPTION

All user-facing routes in C<Database::BI> are handled by this controller.
See the individual action POD above for per-endpoint documentation.

=head2 Filter operators

The C<f=col:op:val> filter spec supports:

  eq        case-insensitive string equality
  ne        case-insensitive string inequality
  contains  case-insensitive substring match
  starts    case-insensitive prefix match
  lt        numeric less-than

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/cgi_security.t  view on Meta::CPAN

	# Proof: /\b([a-z]{2})\b/ extracts at most 2 chars; "<script>" has no
	# two-letter match in a word-boundary context => $lang is undef => $default.
	$t->get_ok('/', {
		'Accept-Language' => '<script>alert(1)</script>'
	})->status_is(200)
	  ->content_unlike(qr/<script>alert\(1\)<\/script>/,
	     'XSS in Accept-Language is not reflected');
};

# ---------------------------------------------------------------------------
# Attack vector 11: API endpoint parameter hardening
# ---------------------------------------------------------------------------

subtest 'GET /api/stat -- null byte path returns exists:false, not a crash' => sub {
	# Exploit mechanism: "/tmp/file.csv\x00../../etc" -- C-library realpath(3)
	# truncates at null; Perl 5.12+ eval catches the exception or the truncated
	# path is non-existent.  Either way, defined $file is false => {exists:false}.
	# Proof: response is HTTP 200 (no crash) with JSON exists:false.
	my $null = "/tmp/file.csv\x00../../etc";
	$t->get_ok('/api/stat?path=' . url_escape($null))
	  ->status_is(200)

t/edge_cases.t  view on Meta::CPAN

	};

	$t->get_ok('/open?path=' . url_escape($path->to_string))
	  ->status_is(200)
	  ->content_like(qr/Could not open/i, 'error_file_open message in error page');

	restore_all();
};

# ---------------------------------------------------------------------------
# Section 10: SSRF via GET /import endpoint -- HTTP-level verification
#
# For blocked IPs the controller short-circuits before any LWP call, so
# these are fast and deterministic (no network required).
# ---------------------------------------------------------------------------

subtest 'GET /import -- private IP in URL triggers SSRF error response' => sub {
	for my $ip ('127.0.0.1', '10.0.0.1', '192.168.1.1', '172.16.0.1', '169.254.169.254', '100.64.0.0') {
		$t->get_ok("/import?url=" . url_escape("http://$ip/"))
		  ->status_is(200)
		  ->content_like(qr/private or reserved address/i,

t/function.t  view on Meta::CPAN

	ok !$fn->('http://10.255.255.255/'),      '10.255.255.255 blocked (10/8 boundary)';
	ok !$fn->('http://172.16.0.1/'),          '172.16.0.1 blocked (172.16/12)';
	ok !$fn->('http://172.31.255.255/'),      '172.31.255.255 blocked (172.16/12 boundary)';
	ok !$fn->('http://192.168.0.1/'),         '192.168.0.1 blocked (192.168/16)';
	ok !$fn->('http://192.168.255.255/'),     '192.168.255.255 blocked (192.168/16 boundary)';
};

subtest '_is_safe_url -- link-local and CGNAT ranges are blocked' => sub {
	my $fn = \&Database::BI::Controller::Dashboard::_is_safe_url;

	ok !$fn->('http://169.254.169.254/'),     'AWS metadata endpoint blocked (169.254/16)';
	ok !$fn->('http://169.254.0.1/'),         '169.254.0.1 blocked (link-local)';
	ok !$fn->('http://100.64.0.1/'),          '100.64.0.1 blocked (CGNAT 100.64/10)';
	ok !$fn->('http://100.127.255.255/'),     '100.127.255.255 blocked (CGNAT boundary)';
};

subtest '_is_safe_url -- public addresses are allowed' => sub {
	my $fn = \&Database::BI::Controller::Dashboard::_is_safe_url;

	ok $fn->('http://8.8.8.8/'),              '8.8.8.8 (Google DNS) allowed';
	ok $fn->('http://1.1.1.1/'),              '1.1.1.1 (Cloudflare) allowed';

t/integration.t  view on Meta::CPAN


# ---------------------------------------------------------------------------
# Integration test suite for Database::BI
#
# Focus: cross-module, multi-step workflows where Database::BI (routing/config),
# Database::BI::Controller::Dashboard (HTTP actions), and
# Database::BI::Model::DataSource (data access) all interact.
#
# What is NOT here (covered by dedicated suites):
#   - Individual filter operators             -> t/filter.t
#   - Single-endpoint status codes            -> t/unit.t
#   - Internal helpers (white-box)            -> t/function.t
#   - Security / hostile inputs               -> t/cgi_security.t
# ---------------------------------------------------------------------------

use Test::Most;
use Test::Mojo;
use Test::Without::Module;
use Readonly;
use File::Spec     ();
use File::Temp     qw(tempdir tempfile);

t/path.t  view on Meta::CPAN

subtest '_is_safe_url path-I: 172.16-31.x.x (RFC 1918) -> 0' => sub {
	ok !$IS_SAFE->('http://172.16.0.1/'), 'path I: 172.16.x.x blocked';
	ok !$IS_SAFE->('http://172.31.255.254/'), 'path I: 172.31.x.x upper bound blocked';
};

subtest '_is_safe_url path-J: 192.168.x.x (RFC 1918) -> 0' => sub {
	ok !$IS_SAFE->('http://192.168.0.1/'), 'path J: 192.168.x.x blocked';
};

subtest '_is_safe_url path-K: 169.254.x.x (link-local / metadata) -> 0' => sub {
	ok !$IS_SAFE->('http://169.254.169.254/latest/meta-data/'), 'path K: AWS metadata endpoint blocked';
};

subtest '_is_safe_url path-L: 100.64.x.x (CGNAT / Tailscale) -> 0' => sub {
	ok !$IS_SAFE->('http://100.64.0.1/'), 'path L: CGNAT range blocked';
};

subtest '_is_safe_url path-M: public IP 8.8.8.8 -> 1 (no private range matches)' => sub {
	ok $IS_SAFE->('http://8.8.8.8/'), 'path M: public IP 8.8.8.8 allowed';
};

t/transaction.t  view on Meta::CPAN

	$t->get_ok('/export?l=table:sales&format=csv&f=amount:gt:1500&f=region:eq:North')
		->status_is(200);

	is count_csv_rows($t->tx->res->body), $NORTH_GT1500,
		'Commutativity: reversed filter order produces same S2 count';
};

# ======================================================================
# TRANSACTION 7: Columns API -> Join coordination
#
# The columns_api endpoint feeds the join panel UI with column names.
# This transaction verifies that:
#   Phase 1  GET /api/columns?table=sales  -> columns list
#   Phase 2  The "region" column from the API can serve as a join key
#   Phase 3  GET /join using that key -> merged result
#   Phase 4  Column count in result is correct
# ======================================================================

subtest 'Transaction 7: Columns API -> join coordination' => sub {
	# ------------------------------------------------------------------
	# Phase 1: fetch columns for the sales table.



( run in 3.490 seconds using v1.01-cache-2.11-cpan-9789f410c06 )