Skip to content

Commit 8142a46

Browse files
authored
Merge pull request #293 from GaneshPatil7517/fix/issue-261-command-injection
2 parents c454795 + 3bc84bc commit 8142a46

1 file changed

Lines changed: 125 additions & 16 deletions

File tree

fri/server/main.py

Lines changed: 125 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,80 @@
77
from pathlib import Path
88
import json
99
import platform
10+
import re
1011
from flask_cors import CORS, cross_origin
1112

13+
# Input validation pattern for safe names (alphanumeric, dash, underscore, slash, dot, space)
14+
SAFE_INPUT_PATTERN = re.compile(r'^[a-zA-Z0-9_\-/. ]+$')
15+
# Pattern for filenames - no path separators or .. allowed
16+
SAFE_FILENAME_PATTERN = re.compile(r'^[a-zA-Z0-9_\-. ]+$')
17+
18+
def validate_input(value, field_name, required=False):
19+
"""Validate that input contains only safe characters."""
20+
if value is None:
21+
if required:
22+
raise ValueError(f"Missing required field: {field_name}")
23+
return True
24+
if not isinstance(value, str):
25+
raise ValueError(f"Invalid {field_name}: must be a string")
26+
if required and len(value) == 0:
27+
raise ValueError(f"Missing required field: {field_name}")
28+
if len(value) > 0 and not SAFE_INPUT_PATTERN.match(value):
29+
raise ValueError(f"Invalid {field_name}: contains unsafe characters")
30+
return True
31+
32+
def validate_filename(value, field_name, required=False):
33+
"""Validate filename - no path separators or .. segments allowed."""
34+
if value is None:
35+
if required:
36+
raise ValueError(f"Missing required field: {field_name}")
37+
return True
38+
if not isinstance(value, str):
39+
raise ValueError(f"Invalid {field_name}: must be a string")
40+
if required and len(value) == 0:
41+
raise ValueError(f"Missing required field: {field_name}")
42+
# Reject path traversal attempts
43+
if '..' in value:
44+
raise ValueError(f"Invalid {field_name}: path traversal not allowed")
45+
# Use basename to strip any path components
46+
basename = os.path.basename(value)
47+
if basename != value:
48+
raise ValueError(f"Invalid {field_name}: must be a filename, not a path")
49+
if len(value) > 0 and not SAFE_FILENAME_PATTERN.match(value):
50+
raise ValueError(f"Invalid {field_name}: contains unsafe characters")
51+
return True
52+
53+
def validate_text_field(value, field_name, max_length=None):
54+
"""Validate text fields like PR title/body - allow more characters but check type/length."""
55+
if value is None:
56+
return True
57+
if not isinstance(value, str):
58+
raise ValueError(f"Invalid {field_name}: must be a string")
59+
if max_length and len(value) > max_length:
60+
raise ValueError(f"Invalid {field_name}: too long (max {max_length} characters)")
61+
return True
62+
63+
def get_error_output(e):
64+
"""Extract error output from CalledProcessError, preferring stderr then output."""
65+
raw_output = None
66+
if hasattr(e, 'stderr') and e.stderr:
67+
raw_output = e.stderr
68+
elif hasattr(e, 'output') and e.output:
69+
raw_output = e.output
70+
71+
if raw_output is None:
72+
return "Command execution failed"
73+
74+
if isinstance(raw_output, bytes):
75+
try:
76+
return raw_output.decode('utf-8', errors='replace')
77+
except Exception:
78+
return str(raw_output)
79+
elif isinstance(raw_output, str):
80+
return raw_output
81+
else:
82+
return str(raw_output)
83+
1284
cur_path = os.path.dirname(os.path.abspath(__file__))
1385
concore_path = os.path.abspath(os.path.join(cur_path, '../../'))
1486

@@ -306,20 +378,34 @@ def clear(dir):
306378
def contribute():
307379
try:
308380
data = request.json
309-
PR_TITLE = data.get('title')
310-
PR_BODY = data.get('desc')
311-
AUTHOR_NAME = data.get('auth')
312-
STUDY_NAME = data.get('study')
313-
STUDY_NAME_PATH = data.get('path')
314-
BRANCH_NAME = data.get('branch')
381+
PR_TITLE = data.get('title') or ''
382+
PR_BODY = data.get('desc') or ''
383+
AUTHOR_NAME = data.get('auth') or ''
384+
STUDY_NAME = data.get('study') or ''
385+
STUDY_NAME_PATH = data.get('path') or ''
386+
BRANCH_NAME = data.get('branch') or ''
387+
388+
# Validate all user inputs to prevent command injection
389+
# Strict validation for names/paths that go into command arguments
390+
validate_input(STUDY_NAME, 'study', required=True)
391+
validate_input(STUDY_NAME_PATH, 'path', required=True)
392+
validate_input(AUTHOR_NAME, 'auth', required=True)
393+
validate_input(BRANCH_NAME, 'branch', required=False)
394+
395+
# For PR title/body, allow more characters but enforce type/length
396+
validate_text_field(PR_TITLE, 'title', max_length=512)
397+
validate_text_field(PR_BODY, 'desc', max_length=8192)
398+
315399
if(platform.uname()[0]=='Windows'):
316-
proc=check_output(["contribute",STUDY_NAME,STUDY_NAME_PATH,AUTHOR_NAME,BRANCH_NAME,PR_TITLE,PR_BODY],cwd=concore_path,shell=True)
400+
# Use cmd.exe /c to invoke contribute.bat on Windows
401+
proc = subprocess.run(["cmd.exe", "/c", "contribute.bat", STUDY_NAME, STUDY_NAME_PATH, AUTHOR_NAME, BRANCH_NAME, PR_TITLE, PR_BODY], cwd=concore_path, check=True, capture_output=True, text=True)
402+
output_string = proc.stdout
317403
else:
318404
if len(BRANCH_NAME)==0:
319405
proc = check_output([r"./contribute",STUDY_NAME,STUDY_NAME_PATH,AUTHOR_NAME],cwd=concore_path)
320406
else:
321407
proc = check_output([r"./contribute",STUDY_NAME,STUDY_NAME_PATH,AUTHOR_NAME,BRANCH_NAME,PR_TITLE,PR_BODY],cwd=concore_path)
322-
output_string = proc.decode()
408+
output_string = proc.decode()
323409
status=200
324410
if output_string.find("/pulls/")!=-1:
325411
status=200
@@ -328,6 +414,11 @@ def contribute():
328414
else:
329415
status=400
330416
return jsonify({'message': output_string}),status
417+
except ValueError as e:
418+
return jsonify({'message': str(e)}), 400
419+
except subprocess.CalledProcessError as e:
420+
output_string = get_error_output(e)
421+
return jsonify({'message': output_string}), 501
331422
except Exception as e:
332423
output_string = "Some Error occured.Please try after some time"
333424
status=501
@@ -397,18 +488,36 @@ def library(dir):
397488
dir_path = os.path.abspath(os.path.join(concore_path, dir_name))
398489
filename = request.args.get('filename')
399490
library_path = request.args.get('path')
400-
proc = 0
491+
492+
# Validate user inputs to prevent command injection
493+
try:
494+
# Use strict filename validation - no path separators or .. allowed
495+
validate_filename(filename, 'filename', required=True)
496+
validate_input(library_path, 'path', required=False)
497+
except ValueError as e:
498+
resp = jsonify({'message': str(e)})
499+
resp.status_code = 400
500+
return resp
501+
401502
if (library_path == None or library_path == ''):
402503
library_path = r"../tools"
403-
if(platform.uname()[0]=='Windows'):
404-
proc = subprocess.check_output([r"..\library", library_path, filename],shell=True, cwd=dir_path)
405-
else:
406-
proc = subprocess.check_output([r"../library", library_path, filename], cwd=dir_path)
407-
if(proc != 0):
408-
resp = jsonify({'message': proc.decode("utf-8")})
504+
try:
505+
if(platform.uname()[0]=='Windows'):
506+
# Use cmd.exe /c to invoke library.bat on Windows
507+
result = subprocess.run(["cmd.exe", "/c", r"..\library.bat", library_path, filename], cwd=dir_path, check=True, capture_output=True, text=True)
508+
proc = result.stdout
509+
else:
510+
proc = subprocess.check_output([r"../library", library_path, filename], cwd=dir_path)
511+
proc = proc.decode("utf-8")
512+
resp = jsonify({'message': proc})
409513
resp.status_code = 201
410514
return resp
411-
else:
515+
except subprocess.CalledProcessError as e:
516+
error_output = get_error_output(e)
517+
resp = jsonify({'message': f'Command execution failed: {error_output}'})
518+
resp.status_code = 500
519+
return resp
520+
except Exception as e:
412521
resp = jsonify({'message': 'There is an Error'})
413522
resp.status_code = 500
414523
return resp

0 commit comments

Comments
 (0)