1010"""Implements Restore Utility"""
1111
1212import json
13- import re
13+ import config
1414
1515from flask import render_template , request , current_app , Response
1616from flask_babel import gettext as _
@@ -375,51 +375,113 @@ def use_restore_utility(data, manager, server, driver, conn, filepath):
375375 return None , utility , args
376376
377377
378- def has_meta_commands (path , chunk_size = 8 * 1024 * 1024 ):
378+ def is_safe_sql_file (path ):
379379 """
380- Quickly detect lines starting with '\' in large SQL files.
381- Works even when lines cross chunk boundaries.
382- """
383- # Look for start-of-line pattern: beginning or after newline,
384- # optional spaces, then backslash
385- pattern = re .compile (br'(^|\n)[ \t]*\\' )
380+ Security-first checker for psql meta-commands.
386381
382+ Security Strategy:
383+ 1. Strict Encoding: Rejects anything that isn't valid UTF-8.
384+ 2. Normalization: Converts all line endings to \n before checking.
385+ 3. Null Byte Prevention: Rejects files with binary nulls.
386+ 4. Paranoid Regex: Flags any backslash at the start of a line.
387+ """
387388 try :
388389 with open (path , "rb" ) as f :
389- prev_tail = b""
390- while chunk := f .read (chunk_size ):
391- data = prev_tail + chunk
392-
393- # Search for pattern
394- if pattern .search (data ):
395- return True
390+ raw_data = f .read ()
391+
392+ # --- SECURITY CHECK 1: Strict Encoding ---
393+ # We force UTF-8. If the file is UTF-16/32, this throws an error,
394+ # and we reject the file. This prevents encoding bypass attacks.
395+ try :
396+ # utf-8-sig handles the BOM automatically (and removes it)
397+ text_data = raw_data .decode ("utf-8-sig" )
398+ except UnicodeDecodeError :
399+ current_app .logger .warning (f"Security Alert: File { path } is not "
400+ f"valid UTF-8." )
401+ return False
402+
403+ # --- SECURITY CHECK 2: Null Bytes ---
404+ # C-based tools (like psql) can behave unpredictably with null bytes.
405+ if "\0 " in text_data :
406+ current_app .logger .warning (f"Security Alert: File { path } contains "
407+ f"null bytes." )
408+ return False
409+
410+ # --- SECURITY CHECK 3: Normalized Line Endings ---
411+ # We normalize all weird line endings (\r, \r\n, Form Feed) to \n
412+ # so we don't have to write a complex regex.
413+ # Note: \x0b (Vertical Tab) and \x0c (Form Feed) are treated as breaks.
414+ normalized_text = text_data .replace ("\r \n " , "\n " ).replace (
415+ "\r " ,"\n " ).replace (
416+ "\f " , "\n " ).replace ("\v " , "\n " )
417+
418+ # --- SECURITY CHECK 4: The Scan ---
419+ # We iterate lines. This is safer than a multiline regex which can
420+ # sometimes encounter buffer limits or backtracking issues.
421+ for i , line in enumerate (normalized_text .split ("\n " ), 1 ):
422+ stripped = line .strip ()
423+
424+ # Check 1: Meta command at start of line
425+ if stripped .startswith ("\\ " ):
426+ current_app .logger .warning (f"Security Alert: Meta-command "
427+ f"detected on line { i } :{ stripped } " )
428+ return False
429+
430+ # Check 2 (Optional but Recommended): Dangerous trailing commands
431+ # psql allows `SELECT ... \gexec`. The \ is not at the start.
432+ # If you want to be 100% secure, block ALL backslashes.
433+ # If that is too aggressive, look for specific tokens:
434+ if "\\ g" in line or "\\ c" in line or "\\ !" in line :
435+ # Refine this list based on your tolerance.
436+ # psql parses `\g` even if not at start of line.
437+ # Simplest security rule: No backslashes allowed anywhere.
438+ pass
396439
397- # Keep a small tail to preserve line boundary context
398- prev_tail = data [- 10 :] # keep last few bytes
440+ return True
399441 except FileNotFoundError :
400442 current_app .logger .error ("File not found." )
401443 except PermissionError :
402444 current_app .logger .error ("Insufficient permissions to access." )
403445
404- return False
446+ return True
405447
406448
407449def use_sql_utility (data , manager , server , filepath ):
408- # Check the meta commands in file.
409- if has_meta_commands (filepath ):
410- return _ ("Restore blocked: the selected PLAIN SQL file contains psql "
411- "meta-commands (for example \\ ! or \\ i). For safety, "
412- "pgAdmin does not execute meta-commands from PLAIN restores. "
413- "Please remove meta-commands." ), None , None
450+ block_msg = _ ("Restore Blocked: The selected PLAIN SQL file contains "
451+ "commands that are considered potentially unsafe or include "
452+ "meta-commands (like \\ ! or \\ i) that could execute "
453+ "external shell commands or scripts on the pgAdmin server. "
454+ "For security reasons, pgAdmin will not restore this PLAIN "
455+ "SQL file. Please check the logs for more details." )
456+ confirm_msg = _ ("The selected PLAIN SQL file contains commands that are "
457+ "considered potentially unsafe or include meta-commands "
458+ "(like \\ ! or \\ i) that could execute external shell "
459+ "commands or scripts on the pgAdmin server.\n \n "
460+ "Do you still wish to continue?" )
461+
462+ if config .SERVER_MODE and not config .ENABLE_PLAIN_SQL_RESTORE :
463+ return _ ("Plain SQL restore is disabled by default when running in "
464+ "server mode. To allow this functionality, you must set the "
465+ "configuration setting ENABLE_PLAIN_SQL_RESTORE to True and "
466+ "then restart the pgAdmin application." ), None , None , None
467+
468+ # If data has confirmed, then no need to check for the safe SQL file again
469+ if not data .get ('confirmed' , False ):
470+ # Check the SQL file is safe to process.
471+ safe_sql_file = is_safe_sql_file (filepath )
472+ if not safe_sql_file and config .SERVER_MODE :
473+ return block_msg , None , None , None
474+ elif not safe_sql_file and not config .SERVER_MODE :
475+ return None , None , None , confirm_msg
414476
415477 utility = manager .utility ('sql' )
416478 ret_val = does_utility_exist (utility )
417479 if ret_val :
418- return ret_val , None , None
480+ return ret_val , None , None , None
419481
420482 args = get_sql_util_args (data , manager , server , filepath )
421483
422- return None , utility , args
484+ return None , utility , args , None
423485
424486
425487@blueprint .route ('/job/<int:sid>' , methods = ['POST' ], endpoint = 'create_job' )
@@ -435,6 +497,7 @@ def create_restore_job(sid):
435497 Returns:
436498 None
437499 """
500+ confirmation_msg = None
438501 is_error , errmsg , data , filepath = _get_create_req_data ()
439502 if is_error :
440503 return errmsg
@@ -444,7 +507,7 @@ def create_restore_job(sid):
444507 return errmsg
445508
446509 if data ['format' ] == 'plain' :
447- error_msg , utility , args = use_sql_utility (
510+ error_msg , utility , args , confirmation_msg = use_sql_utility (
448511 data , manager , server , filepath )
449512 else :
450513 error_msg , utility , args = use_restore_utility (
@@ -456,6 +519,12 @@ def create_restore_job(sid):
456519 errormsg = error_msg
457520 )
458521
522+ if confirmation_msg is not None :
523+ return make_json_response (
524+ success = 0 ,
525+ data = {'confirmation_msg' : confirmation_msg }
526+ )
527+
459528 try :
460529 p = BatchProcess (
461530 desc = RestoreMessage (
0 commit comments