Files
gniza4cp/cpanel/gniza/lib/GnizaCPanel/UI.pm
shuki 1f68ea1058 Security hardening, static analysis fixes, and expanded test coverage
- Fix CRITICAL: safe config parser replacing shell source, sshpass -e,
  CSRF with /dev/urandom, symlink-safe file I/O
- Fix HIGH: input validation for timestamps/accounts, path traversal
  prevention in Runner.pm, AJAX CSRF on all endpoints
- Fix MEDIUM: umask 077, chmod 700 on config dirs, Config.pm TOCTOU lock,
  rsync exit code capture bug, RSYNC_EXTRA_OPTS character validation
- ShellCheck: fix word-splitting in notify.sh, safe rm in pkgacct.sh,
  suppress cross-file SC2034 false positives
- Perl::Critic: return undef→bare return, return (sort), unpack @_,
  explicit return on void subs, rename Config::write→save
- Remove dead code: enforce_retention_all(), rsync_dry_run()
- Add require_cmd checks for rsync/ssh/hostname/gzip at startup
- Escape $hint/$tip in CGI helper functions for defense-in-depth
- Expand tests from 17→40: validate_timestamp, validate_account_name,
  _safe_source_config (including malicious input), numeric validation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:57:26 +02:00

287 lines
8.3 KiB
Perl

package GnizaCPanel::UI;
# Shared UI helpers for the gniza cPanel user restore plugin.
# Adapted from GnizaWHM::UI for cPanel context (runs as user, not root).
use strict;
use warnings;
use Fcntl qw(:DEFAULT);
my $CSS_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-whm.css';
my $LOGO_FILE = '/usr/local/cpanel/base/frontend/jupiter/gniza/assets/gniza-logo.svg';
# ── HTML Escaping ─────────────────────────────────────────────
sub esc {
my ($str) = @_;
$str //= '';
$str =~ s/&/&amp;/g;
$str =~ s/</&lt;/g;
$str =~ s/>/&gt;/g;
$str =~ s/"/&quot;/g;
$str =~ s/'/&#39;/g;
return $str;
}
# ── Current User ─────────────────────────────────────────────
sub get_current_user {
return $ENV{'REMOTE_USER'} // '';
}
# ── Safe File I/O (symlink-safe) ──────────────────────────────
sub _safe_write {
my ($file, $content) = @_;
# Remove existing file/symlink before writing (prevents symlink attacks)
unlink $file if -e $file || -l $file;
# Create new file with O_CREAT|O_EXCL (fails if file already exists/race)
if (sysopen my $fh, $file, O_WRONLY | O_CREAT | O_EXCL, 0600) {
print $fh $content;
close $fh;
return 1;
}
return 0;
}
sub _safe_read {
my ($file) = @_;
# Refuse to read symlinks
return if -l $file;
return unless -f $file;
if (open my $fh, '<', $file) {
local $/;
my $content = <$fh>;
close $fh;
return $content;
}
return;
}
# ── Flash Messages ────────────────────────────────────────────
sub _flash_file {
my $user = get_current_user();
return "/tmp/.gniza-cpanel-flash-$user";
}
sub set_flash {
my ($type, $text) = @_;
_safe_write(_flash_file(), "$type\n$text\n");
return;
}
sub get_flash {
my $file = _flash_file();
my $content = _safe_read($file);
return unless defined $content;
unlink $file;
my ($type, $text) = split /\n/, $content, 2;
return unless defined $type && defined $text;
chomp $type;
chomp $text;
return ($type, $text);
}
sub render_flash {
my @flash = get_flash();
return '' unless defined $flash[0];
my ($type, $text) = @flash;
# Validate flash type against allowlist
my %valid_types = map { $_ => 1 } qw(success error info warning);
$type = 'info' unless $valid_types{$type // ''};
my $escaped = esc($text);
return qq{<div class="alert alert-$type mb-4">$escaped</div>\n};
}
# ── CSRF Protection ──────────────────────────────────────────
my $_current_csrf_token;
sub _csrf_file {
my $user = get_current_user();
return "/tmp/.gniza-cpanel-csrf-$user";
}
sub generate_csrf_token {
return $_current_csrf_token if defined $_current_csrf_token;
my $token = '';
if (open my $ufh, '<:raw', '/dev/urandom') {
my $bytes;
read $ufh, $bytes, 32;
close $ufh;
$token = unpack('H*', $bytes);
} else {
# Fallback (should not happen on Linux)
for (1..32) {
$token .= sprintf('%02x', int(rand(256)));
}
}
_safe_write(_csrf_file(), time() . "\n" . $token . "\n");
$_current_csrf_token = $token;
return $token;
}
sub verify_csrf_token {
my ($submitted) = @_;
return 0 unless defined $submitted && $submitted ne '';
my $file = _csrf_file();
my $content = _safe_read($file);
return 0 unless defined $content;
my ($stored_time, $stored_token) = split /\n/, $content, 2;
return 0 unless defined $stored_time && defined $stored_token;
chomp $stored_time;
chomp $stored_token;
# Delete after use (single-use)
unlink $file;
# Check expiry (1 hour)
return 0 if (time() - $stored_time) > 3600;
# Constant-time comparison
return 0 if length($submitted) != length($stored_token);
my $result = 0;
for my $i (0 .. length($submitted) - 1) {
$result |= ord(substr($submitted, $i, 1)) ^ ord(substr($stored_token, $i, 1));
}
return $result == 0;
}
sub csrf_hidden_field {
my $token = generate_csrf_token();
return qq{<input type="hidden" name="gniza_csrf" value="} . esc($token) . qq{">};
}
# ── Page Wrappers ────────────────────────────────────────────
sub page_header {
my ($title) = @_;
$title = esc($title // 'gniza Restore');
my $css = '';
if (open my $fh, '<', $CSS_FILE) {
local $/;
$css = <$fh>;
close $fh;
}
$css = _unwrap_layers($css);
$css = _scope_to_container($css);
# Inline logo as base64 data URI
my $logo_html = '';
if (open my $lfh, '<', $LOGO_FILE) {
local $/;
my $svg_data = <$lfh>;
close $lfh;
require MIME::Base64;
my $b64 = MIME::Base64::encode_base64($svg_data, '');
$logo_html = qq{<div class="flex items-center justify-center gap-3 mb-4"><img src="data:image/svg+xml;base64,$b64" alt="gniza" style="height:40px;width:auto"></div>\n};
}
return qq{<style>$css</style>\n}
. qq{<div data-theme="gniza" class="font-sans text-base" style="padding:20px 10px 10px 10px">\n}
. $logo_html;
}
sub page_footer {
return qq{</div>\n};
}
sub _unwrap_layers {
my ($css) = @_;
while ($css =~ /\@layer\s/) {
$css =~ s/\@layer\s+[\w.,\s]+\s*;//g;
my $out = '';
my $i = 0;
my $len = length($css);
while ($i < $len) {
if (substr($css, $i, 6) eq '@layer') {
my $brace = index($css, '{', $i);
if ($brace == -1) { $out .= substr($css, $i); last; }
my $semi = index($css, ';', $i);
if ($semi != -1 && $semi < $brace) {
$i = $semi + 1;
next;
}
my $depth = 1;
my $j = $brace + 1;
while ($j < $len && $depth > 0) {
my $c = substr($css, $j, 1);
$depth++ if $c eq '{';
$depth-- if $c eq '}';
$j++;
}
$out .= substr($css, $brace + 1, $j - $brace - 2);
$i = $j;
} else {
$out .= substr($css, $i, 1);
$i++;
}
}
$css = $out;
}
return $css;
}
sub _scope_to_container {
my ($css) = @_;
$css =~ s/:root,\s*:host/\&/g;
$css =~ s/:where\(:root,\s*\[data-theme[^\]]*\]\)/\&/g;
$css =~ s/:where\(:root\)/\&/g;
$css =~ s/:root,\s*\[data-theme[^\]]*\]/\&/g;
$css =~ s/\[data-theme=light\]/\&/g;
$css =~ s/\[data-theme=gniza\]/\&/g;
$css =~ s/:root:not\(span\)/\&/g;
$css =~ s/:root:has\(/\&:has(/g;
$css =~ s/:root\b/\&/g;
my @top_level;
my $scoped = '';
my $i = 0;
my $len = length($css);
while ($i < $len) {
if (substr($css, $i, 1) eq '@') {
if (substr($css, $i, 11) eq '@keyframes '
|| substr($css, $i, 10) eq '@property ') {
my $brace = index($css, '{', $i);
if ($brace == -1) { $scoped .= substr($css, $i); last; }
my $depth = 1;
my $j = $brace + 1;
while ($j < $len && $depth > 0) {
my $c = substr($css, $j, 1);
$depth++ if $c eq '{';
$depth-- if $c eq '}';
$j++;
}
push @top_level, substr($css, $i, $j - $i);
$i = $j;
next;
}
}
$scoped .= substr($css, $i, 1);
$i++;
}
return join('', @top_level) . '[data-theme="gniza"]{' . $scoped . '}';
}
sub render_errors {
my ($errors) = @_;
return '' unless $errors && @$errors;
my $html = qq{<div class="alert alert-error mb-4">\n<ul class="list-disc pl-5">\n};
for my $err (@$errors) {
$html .= ' <li>' . esc($err) . "</li>\n";
}
$html .= "</ul>\n</div>\n";
return $html;
}
1;