Skip to content

Commit dc6ad98

Browse files
committed
Make Plain SQL restore configurable, disabled by default in server mode. #9368
1 parent 8c153be commit dc6ad98

File tree

12 files changed

+137
-67
lines changed

12 files changed

+137
-67
lines changed

docs/en_US/restore_dialog.rst

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ restore process:
2828
* Select *Custom or tar* to restore from a custom archive file to create a
2929
copy of the backed-up object.
3030
* Select *Plain* to restore a plain SQL backup. When selecting this option
31-
all the other options will not be applicable.
31+
all the other options will not be applicable. **Note:** This option is
32+
disabled by default when running in server mode. To allow this functionality,
33+
you must set the configuration setting ENABLE_PLAIN_SQL_RESTORE to True.
3234
* Select *Directory* to restore from a compressed directory-format archive.
3335

3436
* Enter the complete path to the backup file in the *Filename* field.

web/.editorconfig

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ indent_size = 4
1919
[*.js]
2020
indent_size = 2
2121

22+
[*.jsx]
23+
indent_size = 2
24+
2225
[*.css]
2326
indent_size = 2
2427

web/config.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -967,9 +967,18 @@
967967
#############################################################################
968968
# Number of records to fetch in one batch for server logs.
969969
##############################################################################
970-
971970
ON_DEMAND_LOG_COUNT = 10000
972971

972+
############################################################################
973+
# PLAIN SQL RESTORE
974+
############################################################################
975+
# This will enable plain SQL restore in pgAdmin when running in server mode.
976+
# PLAIN SQL Restore is always enabled in Desktop mode; however, in
977+
# server mode it is disabled by default for security reasons. The
978+
# selected PLAIN SQL file may contain meta-commands (such as \\! or \\i)
979+
# or is considered unsafe to execute on the database server.
980+
ENABLE_PLAIN_SQL_RESTORE = False
981+
973982
#############################################################################
974983
# Patch the default config with custom config and other manipulations
975984
#############################################################################

web/pgadmin/browser/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -524,6 +524,7 @@ def utils():
524524
qt_default_placeholder=QT_DEFAULT_PLACEHOLDER,
525525
vw_edt_default_placeholder=VW_EDT_DEFAULT_PLACEHOLDER,
526526
enable_psql=config.ENABLE_PSQL,
527+
enable_plain_sql_restore=config.ENABLE_PLAIN_SQL_RESTORE,
527528
_=gettext,
528529
auth_only_internal=auth_only_internal,
529530
mfa_enabled=is_mfa_enabled(),

web/pgadmin/browser/templates/browser/js/utils.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ define('pgadmin.browser.utils',
7272
/* Enable server password exec command */
7373
pgAdmin['enable_server_passexec_cmd'] = '{{enable_server_passexec_cmd}}';
7474

75+
/* Plain SQL restore */
76+
pgAdmin['enable_plain_sql_restore'] = '{{enable_plain_sql_restore}}' == 'True';
77+
7578
// Define list of nodes on which Query tool option doesn't appears
7679
let unsupported_nodes = pgAdmin.unsupported_nodes = [
7780
'server_group', 'server', 'coll-tablespace', 'tablespace',

web/pgadmin/evaluate_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,8 @@ def evaluate_and_patch_config(config: dict) -> dict:
131131
config['USER_INACTIVITY_TIMEOUT'] = 0
132132
# Enable PSQL in Desktop Mode.
133133
config['ENABLE_PSQL'] = True
134+
# Enable Plain SQL Restore in Desktop Mode.
135+
config['ENABLE_PLAIN_SQL_RESTORE'] = True
134136

135137
if config.get('SERVER_MODE'):
136138
config.setdefault('USE_OS_SECRET_STORAGE', False)

web/pgadmin/static/js/UtilityView.jsx

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -100,11 +100,22 @@ function UtilityViewContent({schema, treeNodeInfo, actionType, formType, onClose
100100
data: {...data, ...extraData},
101101
}).then((res)=>{
102102
/* Don't warn the user before closing dialog */
103-
resolve(res.data);
104-
onSave?.(res.data, data);
105-
onClose();
103+
if (res.data?.data?.confirmation_msg) {
104+
pgAdmin.Browser.notifier.confirm(
105+
gettext('Warning'),
106+
res.data.data.confirmation_msg,
107+
function() {
108+
resolve(onSaveClick(isNew, {...data, confirmed: true}));
109+
},
110+
reject
111+
);
112+
} else {
113+
resolve(res.data);
114+
onSave?.(res.data, data);
115+
onClose();
116+
}
106117
}).catch((err)=>{
107-
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
118+
reject(err instanceof Error ? err : new Error(gettext('Something went wrong')));
108119
});
109120
});
110121

@@ -120,7 +131,7 @@ function UtilityViewContent({schema, treeNodeInfo, actionType, formType, onClose
120131
resolve(res.data.data);
121132
}).catch((err)=>{
122133
onError(err);
123-
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
134+
reject(err instanceof Error ? err : new Error(gettext('Something went wrong')));
124135
});
125136
});
126137
};
@@ -170,7 +181,7 @@ function UtilityViewContent({schema, treeNodeInfo, actionType, formType, onClose
170181
} else if(err.message){
171182
console.error('error msg', err.message);
172183
}
173-
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
184+
reject(err instanceof Error ? err : new Error(gettext('Something went wrong')));
174185
});
175186
}
176187

web/pgadmin/tools/backup/static/js/backup.js

Lines changed: 0 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -157,17 +157,6 @@ define([
157157
let extraData = this.setExtraParameters(typeOfDialog);
158158
this.showBackupDialog(gettext('Backup Server'), schema, treeItem, typeOfDialog, extraData);
159159
},
160-
saveCallBack: function(data) {
161-
if(data.errormsg) {
162-
pgAdmin.Browser.notifier.alert(
163-
gettext('Error'),
164-
gettext(data.errormsg)
165-
);
166-
} else {
167-
168-
pgBrowser.BgProcessManager.startProcess(data.data.job_id, data.data.desc);
169-
}
170-
},
171160
url_for_utility_exists(id, params){
172161
return url_for('backup.utility_exists', {
173162
'sid': id,

web/pgadmin/tools/maintenance/static/js/maintenance.js

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -92,16 +92,6 @@ define([
9292
}
9393
);
9494
},
95-
saveCallBack: function(data) {
96-
if(data.errormsg) {
97-
pgAdmin.Browser.notifier.alert(
98-
gettext('Error'),
99-
gettext(data.errormsg)
100-
);
101-
} else {
102-
pgBrowser.BgProcessManager.startProcess(data.data.job_id, data.data.desc);
103-
}
104-
},
10595
setExtraParameters(treeInfo) {
10696
let extraData = {};
10797
extraData['database'] = treeInfo.database._label;

web/pgadmin/tools/restore/__init__.py

Lines changed: 96 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
"""Implements Restore Utility"""
1111

1212
import json
13-
import re
13+
import config
1414

1515
from flask import render_template, request, current_app, Response
1616
from 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

407449
def 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

Comments
 (0)