Skip to content

Commit d840176

Browse files
committed
fix(docker): reject a shard DSN holding a line break, and scope the loop's shell options
One DSN per line is the protocol between the script and the entrypoint, and the URL parser strips ASCII line breaks, so a DSN holding one would split into two bogus DSNs with nothing upstream to reject it. The loop now runs in a subshell, so its IFS and noglob changes need no restore and cannot leak into the rest of the entrypoint.
1 parent ca8e793 commit d840176

3 files changed

Lines changed: 30 additions & 11 deletions

File tree

apps/webapp/test/runOpsShardDsns.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,17 @@ describe("shardMigrationDsns", () => {
6161
expect(() => shardMigrationDsns('["postgres://h/a"]')).toThrow(/not an object/i);
6262
});
6363
});
64+
65+
describe("shardMigrationDsns line protocol", () => {
66+
// One DSN per line is the protocol with entrypoint.sh, so a line break would split one DSN into
67+
// two bogus ones. The URL parser strips ASCII line breaks, so nothing upstream rejects this.
68+
it("throws when a DSN holds a line break", () => {
69+
const bad = { key: "a", region: "r", url: "postgres://h/a\npostgres://evil/db" };
70+
expect(() => shardMigrationDsns(JSON.stringify([bad]))).toThrow(/line break/i);
71+
});
72+
73+
it("throws when a directUrl holds a carriage return", () => {
74+
const bad = { key: "a", region: "r", url: "postgres://h/a", directUrl: "postgres://h/a\rx" };
75+
expect(() => shardMigrationDsns(JSON.stringify([bad]))).toThrow(/line break/i);
76+
});
77+
});

docker/scripts/entrypoint.sh

Lines changed: 11 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -65,18 +65,18 @@ if [ -n "$RUN_OPS_SHARDS" ]; then
6565
shard_dsns=$(node scripts/runOpsShardDsns.mjs)
6666
# A `for` loop and NOT `... | while read`: a pipeline subshell would swallow a failed migration
6767
# on any iteration but the last. Here `set -e` stops the boot on the first shard that fails.
68-
# IFS is newline-only so a DSN is never split on other whitespace, and `set -f` stops a DSN
69-
# query string (it holds `?`) from being read as a glob pattern.
70-
old_ifs=$IFS
71-
IFS='
68+
# The whole loop runs in a subshell, so the IFS and `set -f` changes need no restore and cannot
69+
# leak into the rest of the entrypoint. IFS is newline-only so a DSN is never split on other
70+
# whitespace, and `set -f` stops a DSN query string (it holds `?`) from acting as a glob.
71+
(
72+
IFS='
7273
'
73-
set -f
74-
for shard_dsn in $shard_dsns; do
75-
# Subshell with tracing off so `set -x` does not print the DSN (with credentials) to the logs.
76-
(set +x; RUN_OPS_DATABASE_URL="$shard_dsn" DIRECT_URL="$shard_dsn" pnpm --filter @internal/run-ops-database db:migrate:deploy)
77-
done
78-
set +f
79-
IFS=$old_ifs
74+
set -f
75+
for shard_dsn in $shard_dsns; do
76+
# Tracing stays off so `set -x` never prints the DSN (with credentials) to the logs.
77+
RUN_OPS_DATABASE_URL="$shard_dsn" DIRECT_URL="$shard_dsn" pnpm --filter @internal/run-ops-database db:migrate:deploy
78+
done
79+
)
8080
set -x
8181
echo "Run-ops shard migrations done"
8282
else

docker/scripts/runOpsShardDsns.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,11 @@ export function shardMigrationDsns(raw) {
3939
if (typeof dsn !== "string" || dsn === "") {
4040
continue;
4141
}
42+
// One DSN per line IS the protocol with the caller, so a DSN holding a line break would split
43+
// into two bogus DSNs. The URL parser strips ASCII line breaks, so nothing upstream rejects it.
44+
if (/[\r\n]/.test(dsn)) {
45+
throw new Error("RUN_OPS_SHARDS holds a DSN containing a line break");
46+
}
4247
dsns.push(dsn);
4348
}
4449
return dsns;

0 commit comments

Comments
 (0)