From 1238582569aeb98db740d50313f135563916b019 Mon Sep 17 00:00:00 2001
From: Kosta Harlan <kharlan@wikimedia.org>
Date: Mon, 18 May 2026 18:30:44 +0200
Subject: [PATCH 1/2] SECURITY: EasyTimeline: Harden against script injection
 via TextData

Why:
- Embedded newlines in the TextData 'text' attribute could survive
  into the generated ploticus script and inject column-0 directives,
  which ploticus then handed to /bin/sh via #proc getdata command:
- The file.err output channel was reflected back to the requester
  even when written by something other than EasyTimeline.pl's
  Abort() routine

What:
- Pass -noshell on both ploticus invocations in WritePlotFile to
  disable #proc getdata command:, #proc getdata file: wildcard
  expansion, and #shell ... #endshell. EasyTimeline never emits any
  of these directives, so legitimate input is unaffected
- In ParseTextData, strip both real newline characters (introduced
  by ExtractText) and the literal two-char \n sequence (introduced
  by ParseText) from the Text= attribute value, mapping both to
  '~'. This closes the path where newline-ish content survived
  WriteText mode "^" and reached the generated ploticus script
- In Timeline::throwRawException, reflect file.err only when it
  starts with the well-known EasyTimeline Abort() envelope marker
  ("<p>EasyTimeline "). Anything else is logged via the timeline
  channel and replaced with a generic timeline-compilererr message

Assisted-by: Claude Opus 4.7
Bug: T426631
---
 includes/Timeline.php   | 14 ++++++++++++++
 scripts/EasyTimeline.pl | 17 ++++++++++++++++-
 2 files changed, 30 insertions(+), 1 deletion(-)

diff --git a/includes/Timeline.php b/includes/Timeline.php
index 6ce3526..6a60dda 100644
--- a/includes/Timeline.php
+++ b/includes/Timeline.php
@@ -320,9 +320,23 @@ class Timeline implements ParserFirstCallInitHook {
 	/**
 	 * Cleanup and throw errors from EasyTimeline.pl
 	 *
+	 * Only the well-formed error envelope emitted by EasyTimeline.pl's
+	 * Abort() routine is reflected back to the requester. Anything else
+	 * in file.err is treated as attacker-influenced (e.g. via a future
+	 * ploticus injection bug) and is logged server-side only, with a
+	 * generic error surfaced to the user.
+	 *
 	 * @throws TimelineException
 	 */
 	private static function throwRawException( string $err ): never {
+		if ( !str_starts_with( $err, '<p>EasyTimeline ' ) ) {
+			LoggerFactory::getInstance( 'timeline' )->warning(
+				'Unexpected EasyTimeline file.err contents',
+				[ 'error' => $err ]
+			);
+			throw new TimelineException( 'timeline-compilererr', [ '' ] );
+		}
+
 		// Convert the error from poorly-sanitized HTML to plain text
 		$err = strtr( $err, [
 			'</p><p>' => "\n\n",
diff --git a/scripts/EasyTimeline.pl b/scripts/EasyTimeline.pl
index 363f987..a865620 100755
--- a/scripts/EasyTimeline.pl
+++ b/scripts/EasyTimeline.pl
@@ -2724,7 +2724,16 @@ sub ParseTextData {
             }
             elsif ($attribute =~ /^Text$/i) {
                 $text = $attrvalue;
-                $text =~ s/\\n/~/gs;
+                # Strip newline-ish content that would otherwise
+                # survive WriteText mode "^" (which splits only on
+                # caret) and reach the generated ploticus script.
+                # Two forms can arrive here: a real newline char,
+                # produced by ExtractText from a 'text:"...\n..."'
+                # value, and the literal two-char sequence \n that
+                # ParseText derives from '~'. Both are mapped to '~'
+                # so they cannot close out the ploticus 'text:'
+                # attribute and inject column-0 directives.
+                $text =~ s/(?:\n|\\n)/~/gs;
                 if ($text =~ /\~/) {
                     &Warning("TextData attribute 'text' contains ~ (tilde).\n"
                             . "Tilde will not be translated into newline character (only in PlotData)"
@@ -3985,8 +3994,13 @@ sub WritePlotFile {
     print "Running Ploticus to generate svg file $file_vector\n";
 
     my $escaped_font_file = EscapeShellArg($font_file);
+    # -noshell disables ploticus directives that invoke /bin/sh
+    # (#proc getdata command:, #proc getdata file:, #shell ... #endshell).
+    # EasyTimeline never emits any of these, so this is a no-op for
+    # legitimate input but blocks command execution via injection.
     my $cmd =
           EscapeShellArg($pl)
+        . " -noshell"
         . " $map -" . "svg" . " -o "
         . EscapeShellArg($file_vector) . " "
         . EscapeShellArg($file_script)
@@ -4032,6 +4046,7 @@ sub WritePlotFile {
 
     $cmd =
           EscapeShellArg($pl)
+        . " -noshell"
         . " $map -"
         . $image_file_fmt . " -o "
         . EscapeShellArg($file_bitmap) . " "
-- 
2.50.1 (Apple Git-155)

