- Add fallback write when O_EXCL _safe_write fails for CSRF tokens (ensures token is always persisted to disk) - Update SMTP test JS to sync new CSRF token into main form hidden field (prevents stale token after SMTP test consumes the original) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
293 lines
8.5 KiB
Perl
293 lines
8.5 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/&/&/g;
|
|
$str =~ s/</</g;
|
|
$str =~ s/>/>/g;
|
|
$str =~ s/"/"/g;
|
|
$str =~ s/'/'/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)));
|
|
}
|
|
}
|
|
|
|
my $csrf_file = _csrf_file();
|
|
unless (_safe_write($csrf_file, time() . "\n" . $token . "\n")) {
|
|
if (open my $fh, '>', $csrf_file) {
|
|
print $fh time() . "\n" . $token . "\n";
|
|
close $fh;
|
|
}
|
|
}
|
|
|
|
$_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;
|