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);
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 )