Skip to content

Commit 3bc84bc

Browse files
Address review comments: improve validation and Windows compatibility
- Add validate_filename() for strict filename validation (no path traversal) - Add validate_text_field() for PR title/body (allow punctuation, check length) - Add get_error_output() to extract error details from CalledProcessError - Use cmd.exe /c to invoke .bat scripts on Windows for shell=False compatibility - Add required parameter to validate_input() to reject None for required fields - Normalize None to empty string for optional fields - Include captured output in error responses for better diagnostics
1 parent 3b864b4 commit 3bc84bc

1 file changed

Lines changed: 84 additions & 20 deletions

File tree

fri/server/main.py

Lines changed: 84 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -12,17 +12,75 @@
1212

1313
# Input validation pattern for safe names (alphanumeric, dash, underscore, slash, dot, space)
1414
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_\-. ]+$')
1517

16-
def validate_input(value, field_name):
18+
def validate_input(value, field_name, required=False):
1719
"""Validate that input contains only safe characters."""
1820
if value is None:
21+
if required:
22+
raise ValueError(f"Missing required field: {field_name}")
1923
return True
2024
if not isinstance(value, str):
2125
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}")
2228
if len(value) > 0 and not SAFE_INPUT_PATTERN.match(value):
2329
raise ValueError(f"Invalid {field_name}: contains unsafe characters")
2430
return True
2531

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+
2684
cur_path = os.path.dirname(os.path.abspath(__file__))
2785
concore_path = os.path.abspath(os.path.join(cur_path, '../../'))
2886

@@ -312,23 +370,27 @@ def clear(dir):
312370
def contribute():
313371
try:
314372
data = request.json
315-
PR_TITLE = data.get('title')
316-
PR_BODY = data.get('desc')
317-
AUTHOR_NAME = data.get('auth')
318-
STUDY_NAME = data.get('study')
319-
STUDY_NAME_PATH = data.get('path')
320-
BRANCH_NAME = data.get('branch')
373+
PR_TITLE = data.get('title') or ''
374+
PR_BODY = data.get('desc') or ''
375+
AUTHOR_NAME = data.get('auth') or ''
376+
STUDY_NAME = data.get('study') or ''
377+
STUDY_NAME_PATH = data.get('path') or ''
378+
BRANCH_NAME = data.get('branch') or ''
321379

322380
# Validate all user inputs to prevent command injection
323-
validate_input(STUDY_NAME, 'study')
324-
validate_input(STUDY_NAME_PATH, 'path')
325-
validate_input(AUTHOR_NAME, 'auth')
326-
validate_input(BRANCH_NAME, 'branch')
327-
validate_input(PR_TITLE, 'title')
328-
validate_input(PR_BODY, 'desc')
381+
# Strict validation for names/paths that go into command arguments
382+
validate_input(STUDY_NAME, 'study', required=True)
383+
validate_input(STUDY_NAME_PATH, 'path', required=True)
384+
validate_input(AUTHOR_NAME, 'auth', required=True)
385+
validate_input(BRANCH_NAME, 'branch', required=False)
386+
387+
# For PR title/body, allow more characters but enforce type/length
388+
validate_text_field(PR_TITLE, 'title', max_length=512)
389+
validate_text_field(PR_BODY, 'desc', max_length=8192)
329390

330391
if(platform.uname()[0]=='Windows'):
331-
proc = subprocess.run(["contribute",STUDY_NAME,STUDY_NAME_PATH,AUTHOR_NAME,BRANCH_NAME,PR_TITLE,PR_BODY],cwd=concore_path,check=True,capture_output=True,text=True)
392+
# Use cmd.exe /c to invoke contribute.bat on Windows
393+
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)
332394
output_string = proc.stdout
333395
else:
334396
if len(BRANCH_NAME)==0:
@@ -347,7 +409,7 @@ def contribute():
347409
except ValueError as e:
348410
return jsonify({'message': str(e)}), 400
349411
except subprocess.CalledProcessError as e:
350-
output_string = e.stderr if hasattr(e, 'stderr') and e.stderr else "Command execution failed"
412+
output_string = get_error_output(e)
351413
return jsonify({'message': output_string}), 501
352414
except Exception as e:
353415
output_string = "Some Error occured.Please try after some time"
@@ -397,19 +459,20 @@ def library(dir):
397459

398460
# Validate user inputs to prevent command injection
399461
try:
400-
validate_input(filename, 'filename')
401-
validate_input(library_path, 'path')
462+
# Use strict filename validation - no path separators or .. allowed
463+
validate_filename(filename, 'filename', required=True)
464+
validate_input(library_path, 'path', required=False)
402465
except ValueError as e:
403466
resp = jsonify({'message': str(e)})
404467
resp.status_code = 400
405468
return resp
406469

407-
proc = 0
408470
if (library_path == None or library_path == ''):
409471
library_path = r"../tools"
410472
try:
411473
if(platform.uname()[0]=='Windows'):
412-
result = subprocess.run([r"..\library", library_path, filename], cwd=dir_path, check=True, capture_output=True, text=True)
474+
# Use cmd.exe /c to invoke library.bat on Windows
475+
result = subprocess.run(["cmd.exe", "/c", r"..\library.bat", library_path, filename], cwd=dir_path, check=True, capture_output=True, text=True)
413476
proc = result.stdout
414477
else:
415478
proc = subprocess.check_output([r"../library", library_path, filename], cwd=dir_path)
@@ -418,7 +481,8 @@ def library(dir):
418481
resp.status_code = 201
419482
return resp
420483
except subprocess.CalledProcessError as e:
421-
resp = jsonify({'message': 'Command execution failed'})
484+
error_output = get_error_output(e)
485+
resp = jsonify({'message': f'Command execution failed: {error_output}'})
422486
resp.status_code = 500
423487
return resp
424488
except Exception as e:

0 commit comments

Comments
 (0)