Database-BI

 view release on metacpan or  search on metacpan

t/cgi_security.t  view on Meta::CPAN

	  ->status_is(200)
	  ->content_unlike(qr{root:x:0:0},
	     'local file content is not returned');
};

subtest '/import -- ftp:// scheme is rejected' => sub {
	# Proof: "ftp://" does not match /\Ahttps?:\/\//i.
	$t->get_ok('/import?url=' . url_escape('ftp://evil.com/data'))
	  ->status_is(200)
	  ->content_like(qr/not a valid/i,
	     'ftp:// scheme triggers the invalid-URL error');
};

subtest '/import -- javascript: scheme is rejected' => sub {
	# Exploit mechanism: attacker injects a JavaScript URI hoping it executes
	# either server-side or is reflected and executed client-side.
	$t->get_ok('/import?url=' . url_escape('javascript:alert(1)'))
	  ->status_is(200)
	  ->content_like(qr/not a valid/i);
};

subtest '/import -- empty url param triggers required-error message' => sub {
	$t->get_ok('/import?url=')->status_is(200)->content_like(qr/Please enter a URL/i);
};

subtest '/import -- missing url param renders prompt' => sub {
	$t->get_ok('/import')
	  ->status_is(200)
	  ->content_like(qr/Please enter a URL/i);
};

# ---------------------------------------------------------------------------
# Attack vector 5b: SSRF via private/loopback/link-local addresses
#
# _is_safe_url() resolves the hostname and rejects RFC 1918 (10/8, 172.16/12,
# 192.168/16), loopback (127/8), link-local (169.254/16), and CGNAT (100.64/10).
# The response must not attempt a network connection; it renders home with an
# error message.
# ---------------------------------------------------------------------------

subtest '/import -- http://localhost/ is rejected (SSRF loopback)' => sub {
	# Exploit mechanism: attacker fetches internal-only services on the loopback
	# interface (e.g. Redis :6379, internal admin panels, Kubernetes API).
	# Proof: _is_safe_url() matches $host eq 'localhost' and returns 0.
	$t->get_ok('/import?url=' . url_escape('http://localhost/'))
	  ->status_is(200)
	  ->content_like(qr/private or reserved/i,
	     'localhost is rejected as a private address');
};

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
#
# The l= param is parsed by _open_spec.  "table:" accepts only [A-Za-z0-9_]+;
# "path:" requires realpath + EXT_RE; "url:" requires https?:// scheme.
# All other prefixes fall through to the final "return ()" -- 404.
# ---------------------------------------------------------------------------

subtest 'join l= -- path traversal via table: prefix is rejected' => sub {
	# Proof: "table:../../etc" contains '.' and '/' which fail [A-Za-z0-9_]+.
	$t->get_ok('/join?l=' . url_escape('table:../../etc'))
	  ->status_is(404);
};

subtest 'join l= -- path to /etc/passwd via path: prefix is rejected' => sub {
	# Proof: /etc/passwd has no supported extension => EXT_RE fails => 404.
	$t->get_ok('/join?l=' . url_escape('path:/etc/passwd'))
	  ->status_is(404);
};

subtest 'join l= -- javascript: url is rejected' => sub {
	# Proof: _open_spec url: branch requires /\Aurl:(https?:\/\/.+)\z/i.
	$t->get_ok('/join?l=' . url_escape('url:javascript:alert(1)'))
	  ->status_is(404);
};

subtest 'join l= -- unknown spec prefix is rejected' => sub {
	# Proof: _open_spec returns () for any spec that doesn't match table:/path:/url:.
	$t->get_ok('/join?l=' . url_escape('exec:/usr/bin/id'))
	  ->status_is(404);
};

SKIP: {
	skip 'data/sales.csv not found', 1 unless -f $SALES_CSV_PATH;

	subtest 'join j= -- path traversal in right-table spec is silently skipped' => sub {
		# Exploit mechanism: j= with a path: spec pointing to /etc/passwd.
		# Proof: _open_spec rejects /etc/passwd (no ext); step is skipped.
		# Left table (sales) still renders.
		my $hostile = url_escape('path:/etc/passwd|id|id');
		$t->get_ok("/join?l=table:sales&j=$hostile")
		  ->status_is(200);
	};
}

# ---------------------------------------------------------------------------
# Attack vector 7: Header injection via Content-Disposition in GET /export
#
# The export_data action sanitizes the filename stem:
#   (my $safe_name = lc $left_label) =~ s/[^a-z0-9_]+/_/g
# CRLF characters, angle brackets, and quotes are all replaced by '_'.
# The resulting value is safe to embed in the Content-Disposition header.

t/cgi_security.t  view on Meta::CPAN

	$t->post_ok('/upload', form => {
		file => { content => '<?php system("id"); ?>', filename => 'evil.csv.php' }
	})->status_is(415);
};

subtest 'POST /upload -- missing file returns 400' => sub {
	# Proof: $upload->filename guard fires => 400.
	$t->post_ok('/upload', form => { notfile => 'dummy' })->status_is(400)->json_like('/error', qr/No file/i);
};

subtest 'POST /upload -- filename directory traversal is stripped' => sub {
	# Exploit mechanism: "../../evil.csv" as filename hoping to write outside
	# .uploads/.  The upload itself may succeed (extension is valid); what
	# matters is that the file lands in .uploads/<random>/evil.csv, not ../../.
	# Proof: s{.*[/\\]}{} strips "../../" leaving "evil.csv", then stored in
	# a randomly-named subdirectory of .uploads/.
	my $res = $t->post_ok('/upload', form => {
		file => {
			content  => "id,name\n1,safe\n",
			filename => '../../evil.csv',
		}
	})->status_is(200)->tx->res->json;
	like($res->{path}, qr{\.uploads[/\\]}, 'upload path is inside .uploads/');
	unlike($res->{path}, qr{\.\.},     'upload path has no directory traversal');
};

# ---------------------------------------------------------------------------
# Attack vector 10: HTTP header injection via Accept-Language
#
# _resolve_language extracts only [a-z]{2} from Accept-Language.
# CRLF sequences, script tags, and long injected strings are ignored because
# the regex discards everything that isn't a two-letter lowercase code.
# ---------------------------------------------------------------------------

subtest 'Accept-Language -- arbitrary garbage yields default language' => sub {
	# Exploit mechanism: an oversized or random-chars Accept-Language header
	# hoping the regex match fails in an exploitable way (e.g. catastrophic
	# backtracking) or reflects content into the response.
	# Proof: /\b([a-z]{2})(?:-[A-Z]{2})?\b/ produces undef for non-matching
	# strings; undef falls through to the configured default language ("en").
	# The response must be 200 (not 500) and must render the English home page.
	# Note: Mojo correctly refuses to send literal CRLF in request headers
	# (HTTP/1.1 header injection is blocked at the transport layer), so the
	# CRLF injection vector is prevented by the framework itself.
	my $garbage = 'X' x 4096;   # very long, no two-letter lowercase sequence
	$t->get_ok('/', { 'Accept-Language' => $garbage })
	  ->status_is(200);
};

subtest 'Accept-Language -- XSS payload is ignored, falls back to default' => sub {
	# 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)
	  ->json_is('/exists', false);
};

subtest 'GET /api/stat -- missing path returns 400' => sub {
	$t->get_ok('/api/stat')
	  ->status_is(400);
};

subtest 'GET /api/stat -- /etc/passwd returns exists:false (extension guard)' => sub {
	# Exploit mechanism: attacker uses stat_api as a filesystem oracle to map
	# the server's filesystem by probing arbitrary paths for existence, size,
	# and modification time -- useful for fingerprinting or side-channel attacks.
	# Fix: stat_api now requires the path basename to match EXT_RE (supported
	# data file extensions: csv, db, sql, xml, psv).  /etc/passwd has no such
	# extension, so it is treated as non-existent from the caller's perspective.
	$t->get_ok('/api/stat?path=' . url_escape('/etc/passwd'))
	  ->status_is(200)
	  ->json_is('/exists', false, '/etc/passwd is not stat-able via stat_api');
};

subtest 'GET /api/columns -- XSS in table name returns 404' => sub {
	$t->get_ok('/api/columns?table=' . url_escape($XSS_PAYLOAD))
	  ->status_is(404);
};

subtest 'GET /api/columns -- path traversal to /etc/passwd returns 404' => sub {
	$t->get_ok('/api/columns?path=' . url_escape('/etc/passwd'))
	  ->status_is(404);
};

subtest 'GET /api/dirs -- file path returns 404 (must be directory)' => sub {
	$t->get_ok('/api/dirs?path=' . url_escape('/etc/passwd'))
	  ->status_is(404);
};

subtest 'GET /api/dirs -- non-existent path returns 404' => sub {
	$t->get_ok('/api/dirs?path=' . url_escape('/nonexistent/xyzzy'))
	  ->status_is(404);
};

# ---------------------------------------------------------------------------
# Attack vector 12: Oversized upload (DoS via disk/memory exhaustion)
#
# When Mojolicious's max_request_size is exceeded it sets req->is_limit_exceeded
# and still dispatches to the controller (with partial content in the upload
# asset).  upload_file checks is_limit_exceeded first and returns 413 with a
# JSON error before any write to .uploads/.
#
# We prove the guard by temporarily shrinking max_request_size to 1 KiB and
# uploading 5 KiB — identical semantics to the 50 MiB real limit but without



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