Patch 0005

fix: harden saytime.pl temp files and playback.

0005-fix-harden-saytime.pl-temp-files-and-playback.patch

0001 0002 0003 0004 0005 0006 0007
From a2cca5e0f20622e06c2dcd396223ec06fd7e6d84 Mon Sep 17 00:00:00 2001
From: "Jory A. Pratt" <geekypenguin@gmail.com>
Date: Thu, 2 Jul 2026 07:25:53 -0500
Subject: [PATCH 5/7] fix: harden saytime.pl temp files and playback.

Validate configured directory paths, ignore stale cache for other
locations, create announcement audio in a private temp file under the
cache directory, concatenate GSM files without invoking a shell, check
asterisk playback status, and allow more time before cleanup.
---
 bin/saytime.pl | 88 ++++++++++++++++++++++++++++++++++++++++----------
 1 file changed, 71 insertions(+), 17 deletions(-)

diff --git a/bin/saytime.pl b/bin/saytime.pl
index de5831b..c6edb50 100755
--- a/bin/saytime.pl
+++ b/bin/saytime.pl
@@ -1,6 +1,7 @@
 #!/usr/bin/env perl
 use strict;
 use warnings;
+use File::Temp qw(tempfile);
 
 # KD5FMU ASL3 Saytime Weather TimeFormat
 # Uses recorded .gsm sound files from the original Time-Weather-Announce style package.
@@ -31,7 +32,7 @@ if (defined $arg1 && !defined $arg2) {
     $node = $arg2;
     if (defined $arg3) {
         if ($arg3 =~ /^(12|24)$/) { $timefmt = $arg3; }
-        elsif ($arg3 =~ /^[012]$/) { $silent = $arg3; }
+        elsif ($arg3 =~ /^[012]$/) { $silent = 0 + $arg3; }
     }
     if (defined $arg4 && $arg4 =~ /^(12|24)$/) { $timefmt = $arg4; }
 }
@@ -44,17 +45,37 @@ if (!$node || $node eq "YOUR_NODE_NUMBER") {
 }
 
 my $base      = $cfg{SOUNDS_DIR} || "/usr/local/share/asterisk/sounds/custom";
-my $outdir    = "/tmp";
-my $outfile   = "$outdir/current-time.gsm";
 my $cache_dir = $cfg{CACHE_DIR} || "/var/cache/asl3-saytime-weather";
 my $env_file  = "$cache_dir/current.env";
 
+validate_dir_path($base, 'SOUNDS_DIR');
+validate_dir_path($cache_dir, 'CACHE_DIR');
+
+if ($location && -f $env_file) {
+    my %cached = read_ini($env_file);
+    if ($cached{LOCATION_QUERY} && $cached{LOCATION_QUERY} ne $location) {
+        unlink $env_file;
+    }
+}
+
 # Refresh WeatherAPI.com cache if missing or older than 20 minutes.
 if ($location && (!-f $env_file || (time - (stat($env_file))[9]) > 1200)) {
-    system("/usr/local/sbin/asl3-weatherapi-update.sh >/dev/null 2>&1");
+    {
+        local *STDOUT;
+        local *STDERR;
+        open STDOUT, '>>', '/dev/null';
+        open STDERR, '>>', '/dev/null';
+        system('/usr/local/sbin/asl3-weatherapi-update.sh');
+    }
 }
 
-my %wx = (-f $env_file) ? read_ini($env_file) : ();
+my %wx;
+if (-f $env_file) {
+    my %cached = read_ini($env_file);
+    if (!$location || !$cached{LOCATION_QUERY} || $cached{LOCATION_QUERY} eq $location) {
+        %wx = %cached;
+    }
+}
 my @sounds;
 
 push @sounds, build_time_sounds() unless $silent == 2;
@@ -68,14 +89,26 @@ push @sounds, build_weather_sounds(\%wx) if %wx;
 @sounds = grep { defined $_ && -f $_ } @sounds;
 die "No usable .gsm sound files found. Check that sound_files.zip was extracted under $base\n" unless @sounds;
 
-unlink $outfile if -f $outfile;
-my $cat = "cat " . join(' ', map { shell_quote($_) } @sounds) . " > " . shell_quote($outfile);
-system($cat) == 0 or die "Could not create $outfile\n";
-chmod 0644, $outfile;
+my ($outfh, $outfile) = tempfile(
+    'saytime-XXXXXX',
+    SUFFIX => '.gsm',
+    DIR    => $cache_dir,
+    UNLINK => 0,
+);
+close $outfh;
+
+concat_gsm_files(\@sounds, $outfile) or die "Could not create $outfile\n";
+chmod 0640, $outfile;
+
+my $play_base = $outfile;
+$play_base =~ s/\.gsm\z//;
 
 if ($silent == 0) {
-    system('/usr/sbin/asterisk', '-rx', "rpt localplay $node $outdir/current-time");
-    sleep 5;
+    my $rc = system('/usr/sbin/asterisk', '-rx', "rpt localplay $node $play_base");
+    warn "asterisk localplay failed with exit " . ($rc >> 8) . "\n" if $rc != 0;
+    my $sleep_secs = 5 + scalar(@sounds);
+    $sleep_secs = 30 if $sleep_secs > 30;
+    sleep $sleep_secs;
     unlink $outfile;
 } elsif ($silent == 1) {
     print "Saved time and weather sound file to $outfile\n";
@@ -85,6 +118,33 @@ if ($silent == 0) {
 
 exit 0;
 
+sub validate_dir_path {
+    my ($path, $label) = @_;
+    return if defined $path
+        && $path ne '/'
+        && $path =~ m{\A/(?:[^/]+/)*[^/]+\z}
+        && $path !~ /\.\./;
+    die "$label must be an absolute path without .. segments\n";
+}
+
+sub concat_gsm_files {
+    my ($files, $dest) = @_;
+    open my $out, '>', $dest or return 0;
+    binmode $out;
+    for my $file (@$files) {
+        open my $in, '<', $file or return 0;
+        binmode $in;
+        my $buf;
+        while (defined(my $read = read($in, $buf, 65536))) {
+            last if $read == 0;
+            print $out $buf or return 0;
+        }
+        close $in;
+    }
+    close $out;
+    return 1;
+}
+
 sub read_ini {
     my ($file) = @_;
     my %h;
@@ -103,12 +163,6 @@ sub read_ini {
     return %h;
 }
 
-sub shell_quote {
-    my ($s) = @_;
-    $s =~ s/'/'\\''/g;
-    return "'$s'";
-}
-
 sub sound_candidates {
     my ($name) = @_;
     return () unless defined $name && length $name;
-- 
2.47.3