Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
20 changes: 18 additions & 2 deletions web/server/codechecker_server/api/product_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
from .. import permissions
from ..database.config_db_model import IDENTIFIER, Product, ProductPermission
from ..database.database import DBSession, SQLServer, conv, escape_like
from ..routing import is_valid_product_endpoint
from ..routing import is_valid_product_endpoint, is_valid_postgresql_db_name

from .thrift_enum_helper import confidentiality_enum, \
confidentiality_str
Expand Down Expand Up @@ -368,7 +368,10 @@ def __create_product_database(self, product):
with engine.connect() as conn:
conn.execute(text("commit"))
LOG.info("Creating database '%s'", db_name)
conn.execute(text(f"CREATE DATABASE {db_name}"))
quoted_db_name = engine.dialect.identifier_preparer \
.quote_identifier(db_name)
conn.execute(
text(f"CREATE DATABASE {quoted_db_name}"))
conn.close()
except exc.ProgrammingError as e:
LOG.error("ProgrammingError occurred: %s", str(e))
Expand Down Expand Up @@ -411,6 +414,19 @@ def addProduct(self, product):
codechecker_api_shared.ttypes.ErrorCode.GENERAL,
msg)

if dbc.engine == 'postgresql' \
and not is_valid_postgresql_db_name(dbc.database):
msg = (
f"The specified PostgreSQL database name "
f"'{dbc.database}' contains characters that are "
"not allowed (quotes, semicolons, whitespace, or "
"control characters), or is empty, or exceeds "
"PostgreSQL's 63-byte identifier limit.")
LOG.error(msg)
raise codechecker_api_shared.ttypes.RequestFailed(
codechecker_api_shared.ttypes.ErrorCode.DATABASE,
msg)

if self.__server.get_product(product.endpoint):
msg = \
f"A product endpoint '/{product.endpoint}' is already " \
Expand Down
31 changes: 31 additions & 0 deletions web/server/codechecker_server/routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,37 @@ def is_valid_product_endpoint(uripart):
return True


def is_valid_postgresql_db_name(db_name):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please, replace this function to codechecker_common.util module. This routing.py is for other purposes.

"""
Returns whether or not the given string is a safe PostgreSQL database
name for CodeChecker to use.

CodeChecker quotes the database identifier when issuing CREATE DATABASE,
so dashes, leading digits, and PostgreSQL reserved keywords are all
allowed (e.g. "test-product", "1team", "user" are accepted). However,
characters that would break even a quoted identifier, or that are
plainly dangerous in an SQL context, are rejected here so we fail fast
with a clear error rather than producing broken SQL or an unusable
product.
"""
if not db_name or not isinstance(db_name, str):
return False

# PostgreSQL identifiers (even quoted) cannot exceed 63 bytes by
# default. Names longer than this are silently truncated by the
# server, which would produce a product that cannot be reconnected
# to under the name the user provided. Reject them outright.
if len(db_name.encode('utf-8')) > 63:
return False

# Forbidden characters: anything that would prematurely terminate
# the quoted identifier, embed a statement separator, or corrupt the
# connection string. Whitespace is also rejected because a name with
# spaces is almost certainly a typo rather than an intent.
forbidden = set('"\'\\;\x00\r\n\t ')
return not any(c in forbidden for c in db_name)


def is_supported_version(version):
"""
Returns whether or not the given version tag is supported by the current
Expand Down
62 changes: 62 additions & 0 deletions web/server/tests/unit/test_request_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@

from codechecker_server.routing import split_client_GET_request
from codechecker_server.routing import split_client_POST_request
from codechecker_server.routing import is_valid_postgresql_db_name


def get(path, host="http://localhost:8001/"):
Expand Down Expand Up @@ -61,3 +62,64 @@ def test_post(self):

self.assertEqual(post('/DummyProduct/v6.0/FoobarService'),
('DummyProduct', '6.0', 'FoobarService'))


class PostgresqlDbNameValidationTest(unittest.TestCase):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Sorry, I've just noticed that this test is also among the tests of the routing module. Could you place this to a new test file? Thank you!

"""
Tests for is_valid_postgresql_db_name, which guards against dangerous
or unusable PostgreSQL database names before CodeChecker issues a
CREATE DATABASE statement.
"""

def test_accepts_plain_names(self):
"""Names that are legal even as unquoted identifiers are allowed."""
self.assertTrue(is_valid_postgresql_db_name('myapp'))
self.assertTrue(is_valid_postgresql_db_name('codechecker_test'))
self.assertTrue(is_valid_postgresql_db_name('db2'))
self.assertTrue(is_valid_postgresql_db_name('_internal'))

def test_accepts_names_needing_quoting(self):
"""
Names that are legal only as quoted identifiers are allowed, since
CodeChecker always quotes the identifier when creating the database.
These are the cases reported in the bug ticket.
"""
self.assertTrue(is_valid_postgresql_db_name('test-product'))
self.assertTrue(is_valid_postgresql_db_name('1team'))
self.assertTrue(is_valid_postgresql_db_name('my-app-prod'))
# "user" is a PostgreSQL reserved word but valid when quoted.
self.assertTrue(is_valid_postgresql_db_name('user'))

def test_rejects_empty_or_none(self):
"""Empty string and non-string inputs are rejected."""
self.assertFalse(is_valid_postgresql_db_name(''))
self.assertFalse(is_valid_postgresql_db_name(None))
self.assertFalse(is_valid_postgresql_db_name(123))

def test_rejects_dangerous_characters(self):
"""
Characters that could break out of a quoted identifier or embed
an SQL statement are rejected.
"""
self.assertFalse(is_valid_postgresql_db_name('foo"bar'))
self.assertFalse(is_valid_postgresql_db_name("foo'bar"))
self.assertFalse(is_valid_postgresql_db_name('foo;bar'))
self.assertFalse(is_valid_postgresql_db_name('foo\\bar'))
self.assertFalse(is_valid_postgresql_db_name('foo\x00bar'))

def test_rejects_whitespace(self):
"""Names containing any whitespace are rejected."""
self.assertFalse(is_valid_postgresql_db_name('foo bar'))
self.assertFalse(is_valid_postgresql_db_name('foo\tbar'))
self.assertFalse(is_valid_postgresql_db_name('foo\nbar'))
self.assertFalse(is_valid_postgresql_db_name('foo\rbar'))

def test_rejects_overlong_names(self):
"""
PostgreSQL silently truncates identifiers longer than 63 bytes,
which would produce a database CodeChecker cannot reconnect to.
"""
self.assertTrue(is_valid_postgresql_db_name('a' * 63))
self.assertFalse(is_valid_postgresql_db_name('a' * 64))
# 63 characters but more than 63 bytes due to multi-byte encoding.
self.assertFalse(is_valid_postgresql_db_name('é' * 32))
Loading