Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 69 additions & 0 deletions core/api/tts.func.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
<?php

/* This file is part of Jeedom.
*
* Jeedom is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Jeedom is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Jeedom. If not, see <http://www.gnu.org/licenses/>.
*/

/**
* Normalize and whitelist a TTS locale tag, falling back to fr-FR when invalid.
*
* @param string $_lang Locale tag (e.g. "fr_FR", "en-US").
* @return string Normalized locale tag with a hyphen separator, or "fr-FR" if the input does not match the expected shape.
*/
function tts_sanitizeLang(string $_lang): string {
$lang = str_replace('_', '-', $_lang);
if (!preg_match('/^[a-zA-Z]{2}(-[a-zA-Z]{2,3})?$/', $lang)) {
return 'fr-FR';
}
return $lang;
}

/**
* Build a shell-safe espeak pipeline that renders text to an MP3 file.
*
* @param string $_text Text to synthesize.
* @param string $_voice espeak voice identifier.
* @param string $_filename Destination MP3 path.
* @param string $_avconv Transcoder binary name (defaults to "ffmpeg"). Must be a trusted constant, not user input.
* @return string Full shell command, ready for exec/shell_exec.
*/
function tts_buildEspeakCmd(string $_text, string $_voice, string $_filename, string $_avconv = 'ffmpeg'): string {
return 'espeak -v' . escapeshellarg($_voice) . ' ' . escapeshellarg($_text)
. ' --stdout | ' . $_avconv . ' -i - -ar 44100 -ac 2 -ab 192k -f mp3 '
. escapeshellarg($_filename) . ' > /dev/null 2>&1';
}

/**
* Build a shell-safe pico2wave + transcode pipeline that renders text to an MP3 file with volume adjustment.
*
* @param string $_text Text to synthesize.
* @param string $_lang Locale tag, sanitized through tts_sanitizeLang().
* @param string $_volume Volume adjustment in dB, cast through floatval().
* @param string $_md5 Unique identifier used to name the intermediate WAV file.
* @param string $_filename Destination MP3 path.
* @param string $_avconv Transcoder binary name (defaults to "ffmpeg"). Must be a trusted constant, not user input.
* @return string Full shell command, ready for exec/shell_exec.
* @see tts_sanitizeLang()
*/
function tts_buildPicoCmd(string $_text, string $_lang, string $_volume, string $_md5, string $_filename, string $_avconv = 'ffmpeg'): string {
$lang = tts_sanitizeLang($_lang);
$volume = '-af "volume=' . floatval($_volume) . 'dB"';
$cmd = 'pico2wave -l=' . escapeshellarg($lang) . ' -w=' . escapeshellarg($_md5 . '.wav')
. ' ' . escapeshellarg($_text) . ' > /dev/null 2>&1;';
$cmd .= $_avconv . ' -i ' . escapeshellarg($_md5 . '.wav') . ' -ar 44100 ' . $volume
. ' -ac 2 -ab 192k -f mp3 ' . escapeshellarg($_filename)
. ' > /dev/null 2>&1;rm ' . escapeshellarg($_md5 . '.wav');
return $cmd;
}
9 changes: 3 additions & 6 deletions core/api/tts.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
* along with Jeedom. If not, see <http://www.gnu.org/licenses/>.
*/
require_once __DIR__ . "/../php/core.inc.php";
require_once __DIR__ . "/tts.func.php";

if (user::isBan()) {
header("Status: 404 Not Found");
Expand Down Expand Up @@ -85,23 +86,19 @@

try {
if ($engine == 'espeak') {
$voice = init('voice', 'fr+f4');
$avconv = 'avconv';
if (!com_shell::commandExists('avconv')) {
$avconv = 'ffmpeg';
}
$cmd = 'espeak -v' . $voice . ' "' . $text . '" --stdout | ' . $avconv . ' -i - -ar 44100 -ac 2 -ab 192k -f mp3 ' . $filename . ' > /dev/null 2>&1';
$cmd = tts_buildEspeakCmd($text, init('voice', 'fr+f4'), $filename, $avconv);
log::add('tts', 'debug', $cmd);
shell_exec($cmd);
} else if ($engine == 'pico') {
$volume = '-af "volume=' . init('volume', '6') . 'dB"';
$lang = str_replace('_', '-', init('lang', config::byKey('language')));
$avconv = 'avconv';
if (!com_shell::commandExists('avconv')) {
$avconv = 'ffmpeg';
}
$cmd = 'pico2wave -l=' . $lang . ' -w=' . $md5 . '.wav "' . $text . '" > /dev/null 2>&1;';
$cmd .= $avconv . ' -i ' . $md5 . '.wav -ar 44100 ' . $volume . ' -ac 2 -ab 192k -f mp3 ' . $filename . ' > /dev/null 2>&1;rm ' . $md5 . '.wav';
$cmd = tts_buildPicoCmd($text, init('lang', config::byKey('language')), init('volume', '6'), $md5, $filename, $avconv);
log::add('tts', 'debug', $cmd);
shell_exec($cmd);
} else {
Expand Down
159 changes: 159 additions & 0 deletions tests/api/ttsTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

/* This file is part of Jeedom.
*
* Jeedom is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Jeedom is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with Jeedom. If not, see <http://www.gnu.org/licenses/>.
*/

require_once dirname(__DIR__, 2) . '/core/api/tts.func.php';

use PHPUnit\Framework\TestCase;

class ttsTest extends TestCase {

// ─── tts_sanitizeLang ───────────────────────────────────────

/**
* @dataProvider validLangProvider
*/
public function testSanitizeLangAcceptsValid(string $input, string $expected): void {
$this->assertSame($expected, tts_sanitizeLang($input));
}

public function validLangProvider(): array {
return [
'fr_FR' => ['fr_FR', 'fr-FR'],
'en_US' => ['en_US', 'en-US'],
'de-DE' => ['de-DE', 'de-DE'],
'pt-BRA' => ['pt-BRA', 'pt-BRA'],
'fr' => ['fr', 'fr'],
];
}

/**
* @dataProvider maliciousLangProvider
*/
public function testSanitizeLangRejectsInjection(string $input): void {
$this->assertSame('fr-FR', tts_sanitizeLang($input));
}

public function maliciousLangProvider(): array {
return [
'double-quote breakout' => ['fr"; rm -rf /; echo "'],
'subshell' => ['$(whoami)'],
'chained command' => ['fr && cat /etc/passwd'],
'semicolon' => ['en;ls'],
'backtick' => ['fr`id`'],
'path traversal' => ['../../../etc/passwd'],
'empty' => [''],
'too short' => ['a'],
'suffix too long' => ['fr-FRAN'],
];
}

// ─── tts_buildEspeakCmd ────────────────────────────────────

public function testBuildEspeakCmdStructure(): void {
$cmd = tts_buildEspeakCmd('bonjour', 'fr+f4', '/tmp/tts/abc.mp3');
$this->assertStringStartsWith('espeak -v', $cmd);
$this->assertStringContainsString(escapeshellarg('bonjour'), $cmd);
$this->assertStringContainsString(escapeshellarg('fr+f4'), $cmd);
$this->assertStringContainsString(escapeshellarg('/tmp/tts/abc.mp3'), $cmd);
}

/**
* @dataProvider espeakInjectionProvider
*/
public function testBuildEspeakCmdEscapesInput(string $text, string $voice): void {
$cmd = tts_buildEspeakCmd($text, $voice, '/tmp/tts/out.mp3');
$this->assertStringContainsString(escapeshellarg($text), $cmd);
$this->assertStringContainsString(escapeshellarg($voice), $cmd);
}

public function espeakInjectionProvider(): array {
return [
'text: double-quote breakout' => ['hello"; rm -rf /; echo "', 'fr+f4'],
'text: subshell' => ['test$(cat /etc/passwd)', 'fr+f4'],
'text: backtick' => ['test`id`end', 'fr+f4'],
'voice: single-quote escape' => ['bonjour', "fr'; rm -rf /"],
'voice: semicolon' => ['bonjour', 'fr;id'],
];
}

/**
* Run the built command through the actual shell to verify arguments
* are interpreted literally and not as shell code.
*/
public function testBuildEspeakCmdShellInterpretation(): void {
$malicious = '$(touch /tmp/pwned)';
$cmd = tts_buildEspeakCmd($malicious, 'fr+f4', '/tmp/out.mp3');

$echoCmd = str_replace('espeak', 'echo', explode(' --stdout', $cmd)[0]);
$result = shell_exec($echoCmd);
$this->assertStringContainsString($malicious, $result);
$this->assertFileDoesNotExist('/tmp/pwned');
}

public function testBuildEspeakCmdUsesCustomAvconv(): void {
$cmd = tts_buildEspeakCmd('test', 'fr', '/tmp/out.mp3', 'avconv');
$this->assertStringContainsString('| avconv ', $cmd);
}

// ─── tts_buildPicoCmd ───────────────────────────────────────

public function testBuildPicoCmdStructure(): void {
$cmd = tts_buildPicoCmd('bonjour', 'fr_FR', '6', 'abc123', '/tmp/tts/abc123.mp3');
$this->assertStringStartsWith('pico2wave', $cmd);
$this->assertStringContainsString(escapeshellarg('bonjour'), $cmd);
$this->assertStringContainsString(escapeshellarg('fr-FR'), $cmd);
$this->assertStringContainsString('volume=6dB', $cmd);
$this->assertStringContainsString(escapeshellarg('abc123.wav'), $cmd);
$this->assertStringContainsString(escapeshellarg('/tmp/tts/abc123.mp3'), $cmd);
}

public function testBuildPicoCmdSanitizesLang(): void {
$cmd = tts_buildPicoCmd('bonjour', 'fr;cat /etc/passwd', '6', 'abc', '/tmp/out.mp3');
$this->assertStringContainsString(escapeshellarg('fr-FR'), $cmd);
$this->assertStringNotContainsString('/etc/passwd', $cmd);
}

public function testBuildPicoCmdSanitizesVolume(): void {
$cmd = tts_buildPicoCmd('bonjour', 'fr-FR', '6"; rm -rf /', 'abc', '/tmp/out.mp3');
$this->assertStringContainsString('volume=6dB', $cmd);
$this->assertStringNotContainsString('rm -rf', $cmd);
}

/**
* @dataProvider picoInjectionProvider
*/
public function testBuildPicoCmdEscapesInput(string $text, string $lang, string $volume): void {
$md5 = md5($text);
$cmd = tts_buildPicoCmd($text, $lang, $volume, $md5, '/tmp/tts/' . $md5 . '.mp3');
$this->assertStringContainsString(escapeshellarg($text), $cmd);
}

public function picoInjectionProvider(): array {
return [
'text: subshell + backtick' => ['bonjour$(reboot)`id`', 'fr-FR', '6'],
'lang: injection' => ['bonjour', 'fr;cat /etc/passwd', '6'],
'volume: injection' => ['bonjour', 'fr-FR', '6"; rm -rf /'],
'all malicious' => ['$(rm -rf /)', 'en;id', '0$(reboot)'],
];
}

public function testBuildPicoCmdUsesCustomAvconv(): void {
$cmd = tts_buildPicoCmd('test', 'fr', '6', 'abc', '/tmp/out.mp3', 'avconv');
$this->assertStringContainsString('avconv -i', $cmd);
}
}
Loading