mssql: the query runs as a read-only login, one line, no variables; sqlcmd is installed (hq #193)
Proven on a throwaway server: as the administrator a caller's $(SQLCMDPASSWORD) returned the sa password, and a line beginning ':!!' ran a program in the tools container. The statement now runs as mesh_mssql_reader (CONNECT ANY DATABASE, SELECT ALL USER SECURABLES), with substitution off (-x), after the module's own text on the first line, and a line break is refused. go-sqlcmd v1.10.0 is installed at a pinned digest: the image never had sqlcmd, so every mssql tool failed with spawn sqlcmd ENOENT.
This commit is contained in:
+109
-12
@@ -29,11 +29,40 @@ export interface MssqlConn {
|
||||
readonly port: number;
|
||||
readonly user: string;
|
||||
readonly password: string;
|
||||
/**
|
||||
* The read-only login's password, which the mesh mints for this module (`own-secrets.reader`).
|
||||
* Absent when the mesh has not delivered it: then a caller's statement is refused, never run as
|
||||
* the administrator (novox/hq issue 193).
|
||||
*/
|
||||
readonly readerPassword?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The login a caller's statement runs as (novox/hq issue 193). It may connect to every database and
|
||||
* read every table, and holds no other permission. A statement cannot climb out of a login the way it
|
||||
* could out of a transaction wrapped around it as text, and the administrator — who can run programs
|
||||
* on the server — never runs a caller's text.
|
||||
*/
|
||||
export const READER = "mesh_mssql_reader";
|
||||
|
||||
/** Who a sqlcmd invocation logs in as, and whether the text is a caller's rather than the module's. */
|
||||
interface Invocation {
|
||||
readonly user: string;
|
||||
readonly password: string;
|
||||
/**
|
||||
* A caller's text: sqlcmd substitutes no `$(NAME)` in it, which would read this process's
|
||||
* environment — the administrator's password among it. (Its own commands are kept out by the
|
||||
* caller's text never beginning a line; see readOnlyQuery.)
|
||||
*/
|
||||
readonly caller: boolean;
|
||||
}
|
||||
|
||||
export class MssqlClient {
|
||||
constructor(private readonly conn: MssqlConn) {}
|
||||
|
||||
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
|
||||
private readerReady?: Promise<void>;
|
||||
|
||||
/**
|
||||
* Build from the module's resolved environment. Reads MESH_MSSQL_* first (the documented
|
||||
* names), falling back to the MESH_PROVISION_* keys the manifest already sets on the provisioner
|
||||
@@ -48,7 +77,9 @@ export class MssqlClient {
|
||||
if (!host || !password) {
|
||||
throw new Error("mssql host or admin password is not set — mssql's own code cannot reach the server");
|
||||
}
|
||||
return new MssqlClient({ host, port, user, password });
|
||||
const readerPassword = env.MESH_MSSQL_READER_PASSWORD ??
|
||||
readSecretFile(env.MESH_MSSQL_READER_PASSWORD_FILE);
|
||||
return new MssqlClient({ host, port, user, password, readerPassword });
|
||||
}
|
||||
|
||||
get host(): string {
|
||||
@@ -86,7 +117,12 @@ export class MssqlClient {
|
||||
}
|
||||
|
||||
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */
|
||||
private async sqlcmd(sql: string, database: string, variables: Record<string, string> = {}): Promise<string> {
|
||||
private async sqlcmd(
|
||||
sql: string,
|
||||
database: string,
|
||||
variables: Record<string, string> = {},
|
||||
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false },
|
||||
): Promise<string> {
|
||||
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long
|
||||
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin
|
||||
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships
|
||||
@@ -95,8 +131,9 @@ export class MssqlClient {
|
||||
"sqlcmd",
|
||||
[
|
||||
"-S", `${this.conn.host},${this.conn.port}`,
|
||||
"-U", this.conn.user,
|
||||
"-U", as.user,
|
||||
"-d", database,
|
||||
...(as.caller ? ["-x"] : []),
|
||||
"-C",
|
||||
"-b",
|
||||
"-h", "-1",
|
||||
@@ -107,7 +144,7 @@ export class MssqlClient {
|
||||
],
|
||||
// `variables` reach sqlcmd as environment variables, which it substitutes as `$(NAME)` scripting
|
||||
// variables: a value that must not appear on argv, or in the message of a failed command.
|
||||
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: this.conn.password }, maxBuffer: 16 << 20 },
|
||||
{ env: { ...process.env, ...variables, SQLCMDPASSWORD: as.password }, maxBuffer: 16 << 20 },
|
||||
);
|
||||
return stdout;
|
||||
}
|
||||
@@ -225,16 +262,76 @@ export class MssqlClient {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Run a read-only SELECT against a named database, for the mssql_query tool. */
|
||||
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
||||
// The read-only guarantee is a wrapping transaction that is always rolled back: any write the
|
||||
// statement attempts is undone. The rows are rendered by FOR JSON inside query().
|
||||
const rows = await this.query(
|
||||
`BEGIN TRANSACTION;\n${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;\nROLLBACK;`,
|
||||
database,
|
||||
/**
|
||||
* Make the read-only login, idempotently, with the password the mesh minted for it: it may connect
|
||||
* to every database and read every table, and is taken out of the administrators' role should
|
||||
* anyone have put it there. Run as the administrator, because only it can make a login.
|
||||
*/
|
||||
async ensureReader(): Promise<void> {
|
||||
const password = this.conn.readerPassword;
|
||||
if (!password) throw readerMissing();
|
||||
const logins = await this.query(
|
||||
`SELECT 1 AS ok FROM sys.server_principals WHERE name = ${literal(READER)}`,
|
||||
);
|
||||
return { command: sql.trimStart().split(/\s+/)[0]?.toUpperCase() ?? "", rows };
|
||||
if (logins.length === 0) {
|
||||
await this.exec(
|
||||
`CREATE LOGIN ${ident(READER)} WITH PASSWORD = ${literal(password)}, CHECK_POLICY = OFF`,
|
||||
);
|
||||
} else {
|
||||
await this.exec(`ALTER LOGIN ${ident(READER)} WITH PASSWORD = ${literal(password)}`);
|
||||
await this.exec(`ALTER LOGIN ${ident(READER)} ENABLE`);
|
||||
}
|
||||
await this.exec(
|
||||
`IF IS_SRVROLEMEMBER('sysadmin', ${literal(READER)}) = 1 ` +
|
||||
`ALTER SERVER ROLE sysadmin DROP MEMBER ${ident(READER)}`,
|
||||
);
|
||||
await this.exec(`GRANT CONNECT ANY DATABASE TO ${ident(READER)}`);
|
||||
await this.exec(`GRANT SELECT ALL USER SECURABLES TO ${ident(READER)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a caller's SELECT against a named database as the read-only login, for the mssql_query tool
|
||||
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the
|
||||
* rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
|
||||
* is refused.
|
||||
*/
|
||||
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> {
|
||||
const password = this.conn.readerPassword;
|
||||
if (!password) throw readerMissing();
|
||||
// **One line, refused otherwise.** sqlcmd reads a line that BEGINS with `:` or `!!` as its own
|
||||
// command rather than SQL, and `:!!` starts a program in this container, which holds the
|
||||
// administrator's password. Its switch for refusing those (-X) makes it ignore -Q in the
|
||||
// version shipped here, so instead no line of a caller's text can begin one: the text follows
|
||||
// this module's own on the first line, and a line break in it is refused. Proven on a throwaway
|
||||
// server: the same text at the start of a line ran a program; mid-line it is a syntax error.
|
||||
if (/[\r\n]/.test(sql)) {
|
||||
throw new Error(
|
||||
"mssql_query: the statement must be one line — sqlcmd takes a line beginning with ':' or " +
|
||||
"'!!' as a command of its own, which can start a program (novox/hq issue 193)",
|
||||
);
|
||||
}
|
||||
this.readerReady ??= this.ensureReader().catch((err) => {
|
||||
this.readerReady = undefined; // asked again next call, not failed for the process's life
|
||||
throw err;
|
||||
});
|
||||
await this.readerReady;
|
||||
const stdout = await this.sqlcmd(
|
||||
`SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
|
||||
database,
|
||||
{},
|
||||
{ user: READER, password, caller: true },
|
||||
);
|
||||
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? "";
|
||||
return { command, rows: parseJsonRows(stdout) };
|
||||
}
|
||||
}
|
||||
|
||||
function readerMissing(): Error {
|
||||
return new Error(
|
||||
"the read-only login's password was not delivered (own-secrets.reader, " +
|
||||
"MESH_MSSQL_READER_PASSWORD_FILE), so the statement is refused rather than run as the " +
|
||||
"administrator (novox/hq issue 193)",
|
||||
);
|
||||
}
|
||||
|
||||
/** Generate a URL-safe password. */
|
||||
|
||||
Reference in New Issue
Block a user