Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/en_US/restore_dialog.rst
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@ restore process:
* Select *Custom or tar* to restore from a custom archive file to create a
copy of the backed-up object.
* Select *Plain* to restore a plain SQL backup. When selecting this option
all the other options will not be applicable.
all the other options will not be applicable. **Note:** This option is
disabled by default when running in server mode. To allow this functionality,
you must set the configuration setting ENABLE_PLAIN_SQL_RESTORE to True.
* Select *Directory* to restore from a compressed directory-format archive.

* Enter the complete path to the backup file in the *Filename* field.
Expand Down
3 changes: 3 additions & 0 deletions web/.editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ indent_size = 4
[*.js]
indent_size = 2

[*.jsx]
indent_size = 2

[*.css]
indent_size = 2

Expand Down
11 changes: 10 additions & 1 deletion web/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -967,9 +967,18 @@
#############################################################################
# Number of records to fetch in one batch for server logs.
##############################################################################

ON_DEMAND_LOG_COUNT = 10000

############################################################################
# PLAIN SQL RESTORE
############################################################################
# This will enable plain SQL restore in pgAdmin when running in server mode.
# PLAIN SQL Restore is always enabled in Desktop mode; however, in
# server mode it is disabled by default for security reasons. The
# selected PLAIN SQL file may contain meta-commands (such as \\! or \\i)
# or is considered unsafe to execute on the database server.
ENABLE_PLAIN_SQL_RESTORE = False

#############################################################################
# Patch the default config with custom config and other manipulations
#############################################################################
Expand Down
1 change: 1 addition & 0 deletions web/pgadmin/browser/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -524,6 +524,7 @@ def utils():
qt_default_placeholder=QT_DEFAULT_PLACEHOLDER,
vw_edt_default_placeholder=VW_EDT_DEFAULT_PLACEHOLDER,
enable_psql=config.ENABLE_PSQL,
enable_plain_sql_restore=config.ENABLE_PLAIN_SQL_RESTORE,
_=gettext,
auth_only_internal=auth_only_internal,
mfa_enabled=is_mfa_enabled(),
Expand Down
3 changes: 3 additions & 0 deletions web/pgadmin/browser/templates/browser/js/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,9 @@ define('pgadmin.browser.utils',
/* Enable server password exec command */
pgAdmin['enable_server_passexec_cmd'] = '{{enable_server_passexec_cmd}}';

/* Plain SQL restore */
pgAdmin['enable_plain_sql_restore'] = '{{enable_plain_sql_restore}}' == 'True';

// Define list of nodes on which Query tool option doesn't appears
let unsupported_nodes = pgAdmin.unsupported_nodes = [
'server_group', 'server', 'coll-tablespace', 'tablespace',
Expand Down
2 changes: 2 additions & 0 deletions web/pgadmin/evaluate_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,8 @@ def evaluate_and_patch_config(config: dict) -> dict:
config['USER_INACTIVITY_TIMEOUT'] = 0
# Enable PSQL in Desktop Mode.
config['ENABLE_PSQL'] = True
# Enable Plain SQL Restore in Desktop Mode.
config['ENABLE_PLAIN_SQL_RESTORE'] = True

if config.get('SERVER_MODE'):
config.setdefault('USE_OS_SECRET_STORAGE', False)
Expand Down
23 changes: 17 additions & 6 deletions web/pgadmin/static/js/UtilityView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -100,11 +100,22 @@ function UtilityViewContent({schema, treeNodeInfo, actionType, formType, onClose
data: {...data, ...extraData},
}).then((res)=>{
/* Don't warn the user before closing dialog */
resolve(res.data);
onSave?.(res.data, data);
onClose();
if (res.data?.data?.confirmation_msg) {
pgAdmin.Browser.notifier.confirm(
gettext('Warning'),
res.data.data.confirmation_msg,
function() {
resolve(onSaveClick(isNew, {...data, confirmed: true}));
},
reject
);
} else {
resolve(res.data);
onSave?.(res.data, data);
onClose();
}
}).catch((err)=>{
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
reject(err instanceof Error ? err : new Error(gettext('Something went wrong')));
});
});

Expand All @@ -120,7 +131,7 @@ function UtilityViewContent({schema, treeNodeInfo, actionType, formType, onClose
resolve(res.data.data);
}).catch((err)=>{
onError(err);
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
reject(err instanceof Error ? err : new Error(gettext('Something went wrong')));
});
});
};
Expand Down Expand Up @@ -170,7 +181,7 @@ function UtilityViewContent({schema, treeNodeInfo, actionType, formType, onClose
} else if(err.message){
console.error('error msg', err.message);
}
reject(err instanceof Error ? err : Error(gettext('Something went wrong')));
reject(err instanceof Error ? err : new Error(gettext('Something went wrong')));
});
}

Expand Down
11 changes: 0 additions & 11 deletions web/pgadmin/tools/backup/static/js/backup.js
Original file line number Diff line number Diff line change
Expand Up @@ -157,17 +157,6 @@ define([
let extraData = this.setExtraParameters(typeOfDialog);
this.showBackupDialog(gettext('Backup Server'), schema, treeItem, typeOfDialog, extraData);
},
saveCallBack: function(data) {
if(data.errormsg) {
pgAdmin.Browser.notifier.alert(
gettext('Error'),
gettext(data.errormsg)
);
} else {

pgBrowser.BgProcessManager.startProcess(data.data.job_id, data.data.desc);
}
},
url_for_utility_exists(id, params){
return url_for('backup.utility_exists', {
'sid': id,
Expand Down
10 changes: 0 additions & 10 deletions web/pgadmin/tools/maintenance/static/js/maintenance.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,16 +92,6 @@ define([
}
);
},
saveCallBack: function(data) {
if(data.errormsg) {
pgAdmin.Browser.notifier.alert(
gettext('Error'),
gettext(data.errormsg)
);
} else {
pgBrowser.BgProcessManager.startProcess(data.data.job_id, data.data.desc);
}
},
setExtraParameters(treeInfo) {
let extraData = {};
extraData['database'] = treeInfo.database._label;
Expand Down
123 changes: 96 additions & 27 deletions web/pgadmin/tools/restore/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"""Implements Restore Utility"""

import json
import re
import config

from flask import render_template, request, current_app, Response
from flask_babel import gettext as _
Expand Down Expand Up @@ -375,51 +375,113 @@ def use_restore_utility(data, manager, server, driver, conn, filepath):
return None, utility, args


def has_meta_commands(path, chunk_size=8 * 1024 * 1024):
def is_safe_sql_file(path):
"""
Quickly detect lines starting with '\' in large SQL files.
Works even when lines cross chunk boundaries.
"""
# Look for start-of-line pattern: beginning or after newline,
# optional spaces, then backslash
pattern = re.compile(br'(^|\n)[ \t]*\\')
Security-first checker for psql meta-commands.

Security Strategy:
1. Strict Encoding: Rejects anything that isn't valid UTF-8.
2. Normalization: Converts all line endings to \n before checking.
3. Null Byte Prevention: Rejects files with binary nulls.
4. Paranoid Regex: Flags any backslash at the start of a line.
"""
try:
with open(path, "rb") as f:
prev_tail = b""
while chunk := f.read(chunk_size):
data = prev_tail + chunk

# Search for pattern
if pattern.search(data):
return True
raw_data = f.read()

# --- SECURITY CHECK 1: Strict Encoding ---
# We force UTF-8. If the file is UTF-16/32, this throws an error,
# and we reject the file. This prevents encoding bypass attacks.
try:
# utf-8-sig handles the BOM automatically (and removes it)
text_data = raw_data.decode("utf-8-sig")
except UnicodeDecodeError:
current_app.logger.warning(f"Security Alert: File {path} is not "
f"valid UTF-8.")
return False

# --- SECURITY CHECK 2: Null Bytes ---
# C-based tools (like psql) can behave unpredictably with null bytes.
if "\0" in text_data:
current_app.logger.warning(f"Security Alert: File {path} contains "
f"null bytes.")
return False

# --- SECURITY CHECK 3: Normalized Line Endings ---
# We normalize all weird line endings (\r, \r\n, Form Feed) to \n
# so we don't have to write a complex regex.
# Note: \x0b (Vertical Tab) and \x0c (Form Feed) are treated as breaks.
normalized_text = text_data.replace("\r\n", "\n").replace(
"\r","\n").replace(
"\f", "\n").replace("\v", "\n")

# --- SECURITY CHECK 4: The Scan ---
# We iterate lines. This is safer than a multiline regex which can
# sometimes encounter buffer limits or backtracking issues.
for i, line in enumerate(normalized_text.split("\n"), 1):
stripped = line.strip()

# Check 1: Meta command at start of line
if stripped.startswith("\\"):
current_app.logger.warning(f"Security Alert: Meta-command "
f"detected on line {i}:{stripped}")
return False

# Check 2 (Optional but Recommended): Dangerous trailing commands
# psql allows `SELECT ... \gexec`. The \ is not at the start.
# If you want to be 100% secure, block ALL backslashes.
# If that is too aggressive, look for specific tokens:
if "\\g" in line or "\\c" in line or "\\!" in line:
current_app.logger.warning(f"Security Alert: Dangerous "
f"meta-command pattern detected "
f"on line {i}: {stripped}")
return False

# Keep a small tail to preserve line boundary context
prev_tail = data[-10:] # keep last few bytes
return True
except FileNotFoundError:
current_app.logger.error("File not found.")
except PermissionError:
current_app.logger.error("Insufficient permissions to access.")

return False
return True


def use_sql_utility(data, manager, server, filepath):
# Check the meta commands in file.
if has_meta_commands(filepath):
return _("Restore blocked: the selected PLAIN SQL file contains psql "
"meta-commands (for example \\! or \\i). For safety, "
"pgAdmin does not execute meta-commands from PLAIN restores. "
"Please remove meta-commands."), None, None
block_msg = _("Restore Blocked: The selected PLAIN SQL file contains "
"commands that are considered potentially unsafe or include "
"meta-commands (like \\! or \\i) that could execute "
"external shell commands or scripts on the pgAdmin server. "
"For security reasons, pgAdmin will not restore this PLAIN "
"SQL file. Please check the logs for more details.")
confirm_msg = _("The selected PLAIN SQL file contains commands that are "
"considered potentially unsafe or include meta-commands "
"(like \\! or \\i) that could execute external shell "
"commands or scripts on the pgAdmin server.\n\n "
"Do you still wish to continue?")

if config.SERVER_MODE and not config.ENABLE_PLAIN_SQL_RESTORE:
return _("Plain SQL restore is disabled by default when running in "
"server mode. To allow this functionality, you must set the "
"configuration setting ENABLE_PLAIN_SQL_RESTORE to True and "
"then restart the pgAdmin application."), None, None, None

# If data has confirmed, then no need to check for the safe SQL file again
if not data.get('confirmed', False):
# Check the SQL file is safe to process.
safe_sql_file = is_safe_sql_file(filepath)
if not safe_sql_file and config.SERVER_MODE:
return block_msg, None, None, None
elif not safe_sql_file and not config.SERVER_MODE:
return None, None, None, confirm_msg

Comment on lines 449 to 476
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Server-mode safety check can be bypassed via client-supplied confirmed flag.

In use_sql_utility, is_safe_sql_file runs only when not data.get('confirmed', False). An authenticated client can set confirmed: true in the POST body and completely skip the safety check—even in server mode with ENABLE_PLAIN_SQL_RESTORE enabled—defeating the meta-command protection.

For server mode, the safety check should be unconditional and independent of client input. confirmed should only control the confirmation flow in non-server (desktop) mode. For example:

-    # If data has confirmed, then no need to check for the safe SQL file again
-    if not data.get('confirmed', False):
-        # Check the SQL file is safe to process.
-        safe_sql_file = is_safe_sql_file(filepath)
-        if not safe_sql_file and config.SERVER_MODE:
-            return block_msg, None, None, None
-        elif not safe_sql_file and not config.SERVER_MODE:
-            return None, None, None, confirm_msg
+    if config.SERVER_MODE:
+        # In server mode, always enforce the safety check regardless of
+        # any client-supplied flags.
+        safe_sql_file = is_safe_sql_file(filepath)
+        if not safe_sql_file:
+            return block_msg, None, None, None
+    else:
+        # In desktop mode, allow the user to explicitly confirm once.
+        if not data.get('confirmed', False):
+            safe_sql_file = is_safe_sql_file(filepath)
+            if not safe_sql_file:
+                return None, None, None, confirm_msg

This preserves the intended UX (confirmation only in desktop mode) while ensuring server-mode deployments cannot bypass the security filter with crafted requests.

🤖 Prompt for AI Agents
In web/pgadmin/tools/restore/__init__.py around lines 449–476, the current logic
skips calling is_safe_sql_file when data.get('confirmed') is True which allows a
client to bypass the server-mode safety check; change the flow so that in server
mode is_safe_sql_file is always executed regardless of the client-supplied
confirmed flag (i.e., perform the safety check unconditionally when
config.SERVER_MODE is True and return block_msg if unsafe), while restricting
use of the confirmed flag to only drive the confirmation prompt flow in
non-server (desktop) mode (i.e., if not config.SERVER_MODE and not safe_sql_file
then return confirm_msg), preserving the existing ENABLE_PLAIN_SQL_RESTORE check
and return values.

utility = manager.utility('sql')
ret_val = does_utility_exist(utility)
if ret_val:
return ret_val, None, None
return ret_val, None, None, None

args = get_sql_util_args(data, manager, server, filepath)

return None, utility, args
return None, utility, args, None


@blueprint.route('/job/<int:sid>', methods=['POST'], endpoint='create_job')
Expand All @@ -435,6 +497,7 @@ def create_restore_job(sid):
Returns:
None
"""
confirmation_msg = None
is_error, errmsg, data, filepath = _get_create_req_data()
if is_error:
return errmsg
Expand All @@ -444,7 +507,7 @@ def create_restore_job(sid):
return errmsg

if data['format'] == 'plain':
error_msg, utility, args = use_sql_utility(
error_msg, utility, args, confirmation_msg = use_sql_utility(
data, manager, server, filepath)
else:
error_msg, utility, args = use_restore_utility(
Expand All @@ -456,6 +519,12 @@ def create_restore_job(sid):
errormsg=error_msg
)

if confirmation_msg is not None:
return make_json_response(
success=0,
data={'confirmation_msg': confirmation_msg}
)

try:
p = BatchProcess(
desc=RestoreMessage(
Expand Down
10 changes: 0 additions & 10 deletions web/pgadmin/tools/restore/static/js/restore.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,16 +91,6 @@ define('tools.restore', [
pgBrowser
);
},
saveCallBack: function(data) {
if(data.errormsg) {
pgAdmin.Browser.notifier.alert(
gettext('Error'),
gettext(data.errormsg)
);
} else {
pgBrowser.BgProcessManager.startProcess(data.data.job_id, data.data.desc);
}
},
setExtraParameters: function(treeInfo, nodeData) {
let extraData = {};
extraData['database'] = treeInfo.database._label;
Expand Down
3 changes: 2 additions & 1 deletion web/pgadmin/tools/restore/static/js/restore.ui.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import BaseUISchema from 'sources/SchemaView/base_schema.ui';
import gettext from 'sources/gettext';
import pgAdmin from 'sources/pgadmin';
import { isEmptyString } from 'sources/validators';

export class RestoreSectionSchema extends BaseUISchema {
Expand Down Expand Up @@ -364,7 +365,7 @@ export default class RestoreSchema extends BaseUISchema {
value: 'directory',
}];

if(this.fieldOptions.nodeType == 'database') {
if(pgAdmin['enable_plain_sql_restore'] && this.fieldOptions.nodeType == 'database') {
options.splice(1, 0, {
label: gettext('Plain'),
value: 'plain',
Expand Down
Loading