Conversation
…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.
c24d9ac to
181e678
Compare
|
@maximthomas rebased onto master — the branch had gone conflicting against #885, which landed after this was opened and lifted the drop loop of a clear out of Exactly one line of this PR sat inside that loop and moved with it: the Re-run on the rebased commit, so the clear goes through the extracted method rather than the loop it was written against:
The negative control in the description — Description updated to match. No review feedback has come in yet, so nothing else here has moved. |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The escape sits exactly where the untrusted value enters a log line, and its bounds are the ones the columns lack.
JDBCStorage.forLog(String):3166renders\n/\r/\tand every other ISO control plus U+2028/U+2029, caps atMAX_LOGGED_VALUE_LENGTHwith the exact remainder, and leaves the backslash alone for the normalized-DN reason it states.SkippedRows.add()answers whether the row is described, and that one boolean is what bounds the per-row warns ofreadCatalogRows():5599/:5611/:5633by the same cap whilesize()stays whole.- The negative control in the description —
forLog()taken out of the "no table of this backend" branch, the newTestCasecase red with the line split in two — proves the road the real-clear case drives.
suggestion (non-blocking): The recorded table name on the "names tree %s, whose table %s is not there" line is unbounded in length: isOwnTableName() bounds the charset, not the length.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:2941, :5662-5673
The comment at :2936-2938 says the name "has been through isOwnTableName(), which leaves nothing but a bare identifier"; that method checks a case-insensitive opendj prefix and [A-Za-z0-9_$] and nothing about length, and v is a blob on every engine. A row whose v is opendj followed by megabytes of letters passes the :5631 branch into trees, isExistsTable() answers false for it, and :2941 renders the whole of it — the single-row multi-megabyte record the javadoc of MAX_LOGGED_VALUE_LENGTH (:3140-3146) exists to rule out. Reachable only by a foreign row, one line per such row. The description names this line as "escaped but not capped" and offers to bound it; this is the word.
// JDBCStorage.java:2941 — the recorded name through the same cap; the identity for every real identifier
config.getBackendId(), forLog(tree.getKey().toString()), forLog(tableName)));Or: give isOwnTableName() the identifier bound the engines have anyway (63 postgresql, 64 mysql, 128 sql server, 30/128 oracle), which keeps such a name out of trees — and out of the drop statement — altogether.
suggestion (non-blocking): Of the six forLog() call sites, only the "no table of this backend" description (:5632, the v column) is driven by a test that puts a control character through it.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:5597, :5610, :5631, :2941, :3083, :3087, :3091
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/ClearReportTestCase.java:175
No test writes a k value — recordAnotherTable (TestCase.java:2921) and emptyTheRecordedTableNames (:2931) update v only, every key comes from the storage's own openTree — so the two key-side descriptions and the tree name at :2941 never see a control character; no test stamps a table with a comment other than stale (:1161, :1229), so leftovers.ours (:3364, parsed by the lax TreeName.valueOf, checked by isOwnTree on base DN only) never carries one into :3083. ClearReportTestCase:175 escapes its own descriptions before handing them to reportSkippedRows(), so it re-pins forLog() (already pinned at :77-98) and the constant text, not the wiring. Deleting forLog() at any of the five other sites leaves 7/7 and 82/82 green (by reading, not measured). The roads are reachable: TreeName.valueOf accepts any string with a / at index 0 and another at index ≥ 2.
// TestCase.java — a second real-clear case: the newline in the key, the table not there
final byte[] key = ("/" + baseDn + "/tree\nSEVERE: spliced").getBytes(UTF_8);
insertCatalogRow(con, key, "opendj_nosuchtable"); // v passes isOwnTableName, isExistsTable says no
clearAndCollect();
for (final String line : storage.lines) {
assertFalse(line.indexOf('\n') >= 0 || line.indexOf('\r') >= 0, "a report line was split by the key it carries: " + line);
}Pin: one case per road — a key of no-slash\nSEVERE for :5610, the key above for :2941, and writeTableComment(con, table, "/" + baseDn + "/x\nSEVERE") on a leftover table before the clear for :3083; or the per-site table of forLog()-deleted mutants against ClearReportTestCase + PgSqlTestCase.
suggestion (non-blocking): The per-row warn gates of readCatalogRows() and its end-of-read summary warn are pinned by no test.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:5599, :5611, :5633, :5647-5649
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/ClearReportTestCase.java:153
The three gates and the summary go to logger.warn only; ReportingStorage (TestCase.java:2962) captures reportClearLine alone, and no jdbc test captures the error log. ClearReportTestCase:153 pins add()'s boolean for 100 rows, not that the caller consults it; the two real-clear cases put one foreign row each through the read, so size() never passes 20 and :5647 never fires. Dropping the if around any warn, or deleting :5647-5649, stays green (by reading, not measured). Half of the description's problem statement — "a million logger.warn records on the way in" — is bounded by code nothing holds to.
// ClearReportTestCase — no database needed: readCatalogRows(Connection, String, SkippedRows) wants a ResultSet
final Connection con = mockConnectionReturning(rowsOf(MAX_REPORTED_VALUES + 1, "not-a-tree-name", "opendj_x"));
final SkippedRows skipped = new SkippedRows();
final List<String> warned = captureWarns(() -> storage.readCatalogRows(con, "opendj_catalog", skipped));
assertEquals(warned.stream().filter(w -> w.contains("not the name of a tree")).count(), MAX_REPORTED_VALUES);
assertEquals(warned.stream().filter(w -> w.contains("the first " + MAX_REPORTED_VALUES + " are named above")).count(), 1);Pin: the mocked ResultSet road above with a counting log publisher registered for the case, or a container case seeding MAX_REPORTED_VALUES + 1 foreign rows under the same capture.
suggestion (non-blocking): testForLogBoundsHowMuchOfAValueALineCarries feeds a uniform value, so it pins how much is kept and not which part.
opendj-server-legacy/src/test/java/org/opends/server/backends/jdbc/ClearReportTestCase.java:106-109
5000 identical 'x' make every 200-unit substring equal: a forLog() keeping the last 200 (loop from length - kept), or every other unit, renders the same string and stays green; every other forLog(String) input in src/test is shorter than the cap, so prefix and suffix coincide there too. An operator recognises a value by its head, and nothing says the head is what the line shows.
final StringBuilder huge = new StringBuilder();
for (int i = 0; i < 5000; i++) {
huge.append((char) ('a' + i % 26));
}
final String escaped = JDBCStorage.forLog(huge.toString());
assertTrue(escaped.startsWith(huge.substring(0, JDBCStorage.MAX_LOGGED_VALUE_LENGTH)), "the line does not show the head of the value: " + escaped);suggestion (non-blocking): forLog(String) cuts at MAX_LOGGED_VALUE_LENGTH UTF-16 units, so a supplementary character straddling index 199/200 leaves a lone high surrogate at the end of the kept text.
opendj-server-legacy/src/main/java/org/opends/server/backends/jdbc/JDBCStorage.java:3169-3172
A k value decoded by new String(db2real(key), UTF_8) (:5607) from a 4-byte sequence is a surrogate pair; with the high surrogate at 199 and the low at 200, kept = 200 keeps the high one alone. Character.isISOControl is false for it and it is not U+2028/U+2029, so it is appended verbatim before the ...(+N more characters) tail, and the log encoder writes U+FFFD or ? for it. Cosmetic: a surrogate ends no record, nothing throws, the count is in code units.
int kept = Math.min(value.length(), MAX_LOGGED_VALUE_LENGTH);
if (kept < value.length() && Character.isHighSurrogate(value.charAt(kept - 1))) {
kept--; // never cut a pair in half; the tail counts the unit given back
}note (non-blocking): Not checked.
- NC-1-3 — whether CI discovers
ClearReportTestCase(a bareDirectoryServerTestCasesubclass, no@BeforeClass startServer) and whether the ubuntu cells ran the container suites for the new real-clear case (the description ranPgSqlTestCaselocally only). All fivebuild-maven (ubuntu-latest, *)cells are green on 181e678; the job log itself could not be fetched during this round (api.github.comTLS handshake timeouts). Closes on a-- Executing test: ClearReportTestCaseline, and theTests runlines of the four container suites, in one ubuntu job log. - NC-1-5 — whether a
>63-characteropendj…name throughisExistsTable()/getTables()(:2935) on postgresql can match a truncated existing table: pre-existing at BASE (isOwnTableName()is #893's), outside this PR's lines, needs a postgres container; recorded, not run.
…s not there, and drive every escape from a test The name that line records is read out of the same row as the tree name it carries, and isOwnTableName() bounds the characters of it and not the length - v is a blob on every engine - so it goes through forLog() like every other value now. The number of those lines is read off the catalog just as much: a row reaches the line by recording a name of this backend's namespace that no table goes by, which nothing this backend does bounds the number of, so the same cap bounds how many of them are named and one line says where it cut in. counts.missingTrees goes on counting every such row, the cap taking away the naming and nothing an operator counts by. forLog() cuts between characters and not inside one: the cap counts code units, so a surrogate pair straddling it gives the unit back rather than leaving a high surrogate standing alone to be written out as U+FFFD. Five of the six values these lines escape were driven by no test, and the cap of forLog() was driven by a value of 5000 identical characters, which pins how much is kept and not which part. The three lists of reportClearOutcome() are pinned without a database, leftoverTables() being a method the case hands its lists to; the two key-side roads are real clears off a catalog row the case writes itself, nothing this backend writes putting a key of somebody else's choosing there. The per-row warns of readCatalogRows() and the line at the end of the read are pinned by the error log of the run, which holds every record twice.
|
@maximthomas thank you - all six taken, and the first of them further than it was asked to go. Round 2 is 86bb2b5. S1 - the recorded name on the "whose table is not there" lineTaken, and you were right that the description offered it. config.getBackendId(), forLog(tree.getKey().toString()), forLog(tableName)));And the number of those lines is unbounded in the same way, which the description had wrong. It called them "one line per row of a catalog this backend wrote itself"; they are not. A row reaches that line by recording a name of this backend's namespace that no table goes by, and a foreign writer makes as many of those as it likes - each one a if (missingRows>MAX_REPORTED_VALUES) {
reportClearLine(LocalizableMessage.raw(
"jdbc: backend %s: %d row(s) of its catalog name a tree whose table is not there and the first %d of them are named above, the rest having gone by without a line of their own: there was nothing to drop for any of them",
config.getBackendId(), missingRows, MAX_REPORTED_VALUES));
}
The alternative you offered - a length bound in S2 - five of the six call sites driven by no testRight, and the table below is measured rather than read. The three lists of
The new cap on how many such lines a clear names is pinned by S3 - the per-row warn gates and the end-of-read lineTaken, through a real read rather than a mocked One thing worth writing down for the next case that reads that log: it holds every record twice, one copy per publisher registered for a test run, so the assertions count the records that differ. S4 - a uniform value pins how much and not which partTaken as written: the value is S5 - a surrogate pair cut in halfTaken: int kept=Math.min(value.length(), MAX_LOGGED_VALUE_LENGTH);
if (kept<value.length() && Character.isHighSurrogate(value.charAt(kept-1))) {
kept--;
}The unit given back is counted by the tail like any other. NC-1-3 - what CI ranAnswered from the job log, which came back this time - run
So the offer in the description to run the other three engines is already answered by CI, and this round's four new container cases are run the same way. Tests of this round
|
Fixes #931
readCatalogRows()andreportSkippedRows()came from #893, which has since merged.Problem
A clear accounts for every row of its catalog and for every
opendjtable 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.No cap. A catalog holding a million rows this backend cannot act on was a million
logger.warnrecords on the way in, aListof 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 thekcolumn, decoded as UTF-8, unchecked.treeName—TreeName.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 thevcolumn. In the branch that has just established it is none of this backend's names (isOwnTableName()rejected it), and in the one that has established it is:isOwnTableName()bounds the characters of a name and not its length.The columns bound almost nothing —
k raw(2000), v blobon oracle,k varbinary(255), v longblobon mysql,k varbinary(max), v imageon sql server,k bytea, v byteaon 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 the 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 both values of a row off the same catalog — and a row reaches that line by recording a name of this backend's namespace that no table goes by, which nothing this backend does bounds the number of.
Fix
forLog(String)— renders\n,\rand\tas 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. It cuts between characters and never inside one: the cap counts code units, so a surrogate pair straddling it gives the unit back rather than leaving a high surrogate standing alone. 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 theList<String>: it counts every row passed over and keeps the first 20 descriptions. Whatadd()answers is whether the row is one of the described, which is what bounds the per-row warns ofreadCatalogRows()by the same cap; one line at the end of the read says how many rows went by without one.forLog(), and the same cap bounds how many of those lines a clear reports, with one line where it cuts in.Every count these lines state stays whole —
%d row(s),%d table(s),counts.missingTrees— 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 butreportClearLine(), 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-qualityand has nojava/log-injectionalert here, and would not be expected to: aResultSetread is a source only under thedatabasethreat model, which is off by default. The pattern is real; the fix is not chasing a failing check.Tests
ClearReportTestCaseneeds no database — the escape and both caps are pure functions, the line of passed-over rows is built out of an accumulator a case fills by hand, and the three lists ofreportClearOutcome()are handed in throughleftoverTables()rather than asked of a schema, which is also the only way to put a stamp somebody else wrote into one. Keeping them 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.The container suites drive what only a real clear reaches. Nothing this backend writes puts a key of somebody else's choosing into a catalog, so
insertCatalogRow()writes the row itself:testAClearDoesNotLetACatalogRowEndTheLineReportingIttestAClearDoesNotLetACatalogKeyThatNamesNoTreeEndTheLineReportingIttestAClearDoesNotLetTheRowOfAMissingTableEndTheLineReportingIttestAClearBoundsHowManyRowsOfAMissingTableItNamestestAReadOfACatalogBoundsTheWarningsItWritesPerRowreadCatalogRows()and the one line at the end of the read, off the error log of the runClearReportTestCasePgSqlTestCaseSkipped: 0forLog()call site, the two caps, the surrogate cut and the two warn gatesCI runs all of it:
**/*TestCase.javais one of the failsafe includes (opendj-server-legacy/pom.xml:1249), and one ubuntu job of the previous round ranClearReportTestCase7/7 besideMsSqlTestCase81/0,MySqlTestCase81/0,OracleTestCase81/0 andPgSqlTestCase82/0 — the new container cases run in every one of those four.Named rather than folded in
MAX_REPORTED_VALUESbounds the descriptions of passed-over rows, the listings of leftover tables and the lines naming a tree whose table is not there 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.isOwnTableName()is not part of this. A recorded name longer than any engine's identifier still enters the map a clear walks and reaches the existence lookup; what this PR does is keep it out of a log record. Which names the drop path admits is [#888] Name the trees of a JDBC backend from a catalog in the database #893's rule and a question of its own, which gets an issue of its own together with the postgresql identifier truncation a length bound would also close.