Skip to content

[#931] Bound and escape the values a clear's report reads out of the database - #1008

Open
vharseko wants to merge 1 commit into
OpenIdentityPlatform:masterfrom
vharseko:issues/931-jdbc-clear-report-bounds
Open

[#931] Bound and escape the values a clear's report reads out of the database#1008
vharseko wants to merge 1 commit into
OpenIdentityPlatform:masterfrom
vharseko:issues/931-jdbc-clear-report-bounds

Conversation

@vharseko

@vharseko vharseko commented Sep 9, 2026

Copy link
Copy Markdown
Member

Fixes #931

Lands on top of #893, which is where readCatalogRows() and reportSkippedRows() came from.

Problem

A clear accounts for every row of its catalog and for every opendj table it left standing, and both accounts are built out of values read back off the database: the key of a catalog row, the table name that row records, the comment a table is stamped with. The premise of those lines is a database written into by something other than this backend — so their input is untrusted by construction, and nothing bounded it.

// readCatalogRows(), before: one description per row, no cap, the values as they stand
skippedRows.add(treeName+" at \""+tableName+"\", which is no table of this backend");
...
// reportSkippedRows(), before: the whole List rendered into a single line
reportClearLine(LocalizableMessage.raw("jdbc: backend %s: %d row(s) of its catalog named nothing this clear could drop and were passed over: %s. ...",
    config.getBackendId(), skippedRows.size(), skippedRows));

No cap. A catalog holding a million rows this backend cannot act on was a million logger.warn records on the way in, a List of a million descriptions held for the whole of the clear, and one log record of all of them at the end.

A value could end the record carrying it. A newline in one of them splices the rest of itself into the server log where it reads as further records — the pattern CodeQL names log injection.

What the values actually are

  • name — the whole of the k column, decoded as UTF-8, unchecked.
  • treeNameTreeName.valueOf() requires a / at index 0 and another at index >= 2 (TreeName.java:53-60) and asks nothing else: everything between and after, control characters included, is whatever the row holds.
  • tableName — the whole of the v column, in the branch that has just established it is none of this backend's names (isOwnTableName() rejected it).

The columns bound almost nothing — k raw(2000), v blob on oracle, k varbinary(255), v longblob on mysql, k varbinary(max), v image on sql server, k bytea, v bytea on postgresql — so a single row is enough for a multi-megabyte record, which a cap on the number of rows would not catch.

The same shape stands in two lines beside it: the "tables of this backend its catalog does not name" list embeds the stamp of each table, which is a comment somebody else may write, and the "names tree %s, whose table %s is not there" line carries a tree name off the same catalog.

Fix

  • forLog(String) — renders \n, \r and \t as escapes, every other ISO control character and the two unicode separators as \uXXXX, and bounds the value at 200 characters with a ...(+K more characters) tail. A backslash is left alone: it ends no record, and escaping it would spell every escaped comma of a normalized DN twice over.
  • forLog(Collection<String>) — the first 20 values, each through the above, and a count of what the line is not naming.
  • SkippedRows — replaces the List<String>: it counts every row passed over and keeps the first 20 descriptions. What add() answers is whether the row is one of the described, which is what bounds the per-row warns of readCatalogRows() by the same cap; one line at the end of the read says how many rows went by without one.

Every count these lines state stays whole — %d row(s), %d table(s) — what is capped is the naming. CachedConnection.redact() is the nearest precedent in the package for a value that goes through something on its way to a log; this is the same shape for the opposite direction, keeping somebody else's records out rather than this backend's credentials.

reportSkippedRows() is no longer private: it asks the database nothing and writes to nothing but reportClearLine(), so the bounds of the line it builds are held to by a case that calls it directly.

This is not a red CodeQL alert

The repo scans with security-and-quality and has no java/log-injection alert here, and would not be expected to: a ResultSet read is a source only under the database threat model, which is off by default. The pattern is real; the fix is not chasing a failing check.

Tests

ClearReportTestCase is new and needs no database — the escape and both caps are pure functions, and the line itself is built out of an accumulator a case fills by hand. Keeping it out of the container suites is the point: those skip themselves whole where no docker is reachable, and a bound nothing exercises can be deleted without a test going red.

TestCase.testAClearDoesNotLetACatalogRowEndTheLineReportingIt is the same thing through a real clear against a real database: a catalog row recording a name with a newline in it, and no line of the report carrying a \n or \r afterwards.

suite where result
ClearReportTestCase locally, TestNG under JDK 11 7/7
PgSqlTestCase locally, against a container 80/80, Skipped: 0
PgSqlTestCase, with forLog() taken out of the "no table of this backend" branch locally, against a container 80 run, 1 failure — the new case, the reported line split in two by the value it carried

MySqlTestCase, MsSqlTestCase and OracleTestCase were not run: nothing here is engine-specific — the change is string handling and the wording of report lines — and the case above runs in all four suites. Say the word and I will run them.

Named rather than folded in

  • One cap for both kinds of list. MAX_REPORTED_VALUES bounds the descriptions of passed-over rows and the listings of leftover tables alike. The counts beside them stay exact, but on a database several backends share, the "named by no catalog" listing can now be a sample of a longer list. Two constants, or a larger one, if you would rather.
  • The "whose table %s is not there" line is escaped but not capped: it is one line per row of a catalog this backend wrote itself, and the outcome line sums those rows up as missingTrees anyway. Say the word if it should be bounded like the rest.

…rt reads out of the database

Every value those lines carry was read out of the database, and the premise of
the lines carrying them is a database written into by something other than this
backend. Nothing bounded them: a catalog holding a million rows a clear cannot
act on was one log record of a million descriptions, and a value carrying a
newline spliced the rest of itself into the server log as further records.

forLog() escapes the control characters and the two unicode separators and bounds
a value at 200 characters; SkippedRows counts every row passed over and describes
the first 20, which bounds the per-row warns of readCatalogRows() by the same cap.
Every count these lines state stays whole - what is capped is the naming.

The same escape goes on the lines naming what a clear left standing, the stamp of
a table being a comment somebody else may write, and on the tree name of a row
whose table is gone: TreeName.valueOf() asks for nothing but two slashes.
@vharseko vharseko added bug jdbc tests Test suites: fixing, enabling, un-disabling security Security fixes / CodeQL code-scanning alerts labels Sep 9, 2026
@vharseko
vharseko requested a review from maximthomas September 9, 2026 17:14
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug jdbc security Security fixes / CodeQL code-scanning alerts tests Test suites: fixing, enabling, un-disabling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JDBC backend: a clear renders every catalog row it skipped into one unbounded log line, with the values as they stand

1 participant