mssql: its handlers, tools and provisioner run in the node's runtime, through the driver in its bundle (hq ADR 0198)

The mesh-mssql container goes with its Dockerfile (and the sqlcmd it fetched), build bases and bus credential. The client speaks TDS through the mssql driver its package.json names, inlined into the bundle by the builder (ADR 0198 §4): one session per call as one sqlcmd invocation was, FOR JSON rendering rows exactly as before, the consumer's password checked as a bound parameter. A caller's statement still runs only as the reader login (issue 193); the one-line rule and -x guarded against sqlcmd's own commands and variable substitution, which no longer stand between the caller and the server. The server is reached on loopback at the port the machine published (${port:1433}). The reader test drives a fake session in place of a fake sqlcmd.
This commit is contained in:
jochen
2026-10-04 01:17:41 +02:00
parent da8a46cfe8
commit cf57d3fd8f
8 changed files with 216 additions and 253 deletions
-43
View File
@@ -1,43 +0,0 @@
# mssql's runtime: the tool runtime, carrying this module's compiled code.
#
# **Built from this module's own directory and nothing else.** The sdk and the tool runtime are in
# the base images, published like any other artifact — which is what makes this buildable by the
# mesh from a repository and a path (novox/hq ADR 0069) rather than only on a workstation that
# happens to have the siblings.
#
# Two bases, named rather than pinned (novox/hq issue 044): the image this is COMPILED in and the
# image it RUNS in — the second must not carry a compiler. Declared in module.json's `build.on`.
ARG BUILD_BASE
ARG RUNTIME_BASE
FROM ${BUILD_BASE} AS build
# Compiled under /app/modules so `@novox/mesh-sdk` resolves upward into the base's own
# node_modules — the module is compiled against exactly the sdk it will run against. The compiler
# is invoked by its real path: node_modules/.bin entries are launcher symlinks the base image
# resolved away.
WORKDIR /app/modules/mssql
COPY . .
RUN node /app/node_modules/typescript/bin/tsc client.ts index.ts tools/index.ts provisioner/index.ts \
--module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist
# **sqlcmd, which this module's client drives, has to be here** — it never was, so every tool failed
# with `spawn sqlcmd ENOENT`. go-sqlcmd is one static binary; fetched at a pinned release and checked
# against its digest, so a build that receives anything else stops here.
FROM ${BUILD_BASE} AS sqlcmd
ARG SQLCMD_VERSION=v1.10.0
ARG SQLCMD_SHA256=92516d98c63d99b0994de5b61350c91f6915f9b76f139a59039fbcb225c2e987
RUN apt-get update && apt-get install -y --no-install-recommends curl ca-certificates bzip2 \
&& curl -fsSL -o /tmp/sqlcmd.tar.bz2 \
"https://github.com/microsoft/go-sqlcmd/releases/download/${SQLCMD_VERSION}/sqlcmd-linux-amd64.tar.bz2" \
&& echo "${SQLCMD_SHA256} /tmp/sqlcmd.tar.bz2" | sha256sum -c - \
&& tar -xjf /tmp/sqlcmd.tar.bz2 -C /usr/local/bin sqlcmd
FROM ${RUNTIME_BASE}
COPY --from=sqlcmd /usr/local/bin/sqlcmd /usr/local/bin/sqlcmd
COPY --from=build /app/modules/mssql/dist /app/modules/mssql/dist
# Every serve-time entrypoint, loaded by the runtime in serve mode: tools and events serve, and a
# provider's provisioner runs its reconcile loop in the same process, with the broker connected —
# the convention novox/hq issues 060/061 settled. A container that instead ran only its
# provisioner (`run`) served no tools and emitted no events; a container that named no command
# ran no provisioner at all.
ENV MESH_TOOL_MODULES=/app/modules/mssql/dist/index.js,/app/modules/mssql/dist/tools/index.js,/app/modules/mssql/dist/provisioner/index.js
+107 -98
View File
@@ -1,22 +1,70 @@
// mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this // mssql's admin client — mssql's own code, living in the module (novox/hq ADR 0039). Both this
// module's tools and its provisioner import it, and nothing outside mssql does. // module's tools and its provisioner import it, and nothing outside mssql does.
// //
// SQL is executed through `sqlcmd`, not a wire-protocol driver: the module may take NO npm // **The backend's own driver, inside the bundle** (novox/hq ADR 0198 §4). This used to shell out to
// dependency beyond @novox/mesh-sdk, and hand-rolling the TDS handshake, pre-login and query // `sqlcmd`, a binary the module's container fetched; the module's code now runs in the node's
// protocol is more surface than this should carry — so it shells out to the client the mssql // runtime, on machines whose system carries no SQL Server client, so it speaks TDS through the
// tools ship, the same way postgres drives itself through `psql`, minio through `mc`, and mailu // `mssql` driver its package.json names — installed and inlined into the bundle by the builder. One
// through doveadm. One boundary, `run()`, and every method is built on it. // boundary, `session()`, and every method is built on it: a connection as one login to one database,
// opened for one call and closed after, as one sqlcmd invocation was.
// //
// Structured rows come back as JSON: SQL Server itself renders the result with `FOR JSON`, and // Structured rows still come back as JSON rendered by SQL Server itself (`FOR JSON`), so a tool's
// this parses the single JSON document sqlcmd prints — far more robust than parsing sqlcmd's // answer is shaped exactly as it was: SQL Server owns the quoting and typing.
// column-aligned text, since SQL Server owns the quoting and typing.
import { randomBytes } from "node:crypto"; import { randomBytes } from "node:crypto";
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { execFile } from "node:child_process"; import sql from "mssql";
import { promisify } from "node:util";
const run = promisify(execFile); /** Where a session connects, and as whom. */
export interface Target {
readonly host: string;
readonly port: number;
readonly user: string;
readonly password: string;
readonly database: string;
}
/** One login's connection to one database: run a batch, answer the rows of its last result set. */
export interface Session {
/** `params` are bound as NVARCHAR parameters (`@name`), never written into the text. */
run(text: string, params?: Record<string, string>): Promise<Record<string, unknown>[]>;
close(): Promise<void>;
}
/** How a session is opened — the driver, or a test's fake. */
export type Connect = (to: Target) => Promise<Session>;
/**
* The driver's session: TLS, trusting the self-signed certificate the mssql image ships with (what
* sqlcmd's `-C` did), one connection, closed with the session.
*/
export const connectWithDriver: Connect = async (to) => {
const pool = new sql.ConnectionPool({
server: to.host,
port: to.port,
user: to.user,
password: to.password,
database: to.database,
options: { encrypt: true, trustServerCertificate: true },
pool: { min: 0, max: 1 },
connectionTimeout: 15_000,
requestTimeout: 60_000,
});
await pool.connect();
return {
async run(text, params = {}) {
const request = pool.request();
const names = Object.keys(params);
for (const name of names) request.input(name, sql.NVarChar, params[name]);
// A batch when nothing is bound — CREATE DATABASE must stand alone in its batch, which a
// parameterised query (sp_executesql) is not.
const result = names.length > 0 ? await request.query(text) : await request.batch(text);
const sets = (result.recordsets ?? []) as Record<string, unknown>[][];
return sets.length > 0 ? sets[sets.length - 1] : [];
},
close: () => pool.close(),
};
};
export interface QueryResult { export interface QueryResult {
/** The leading keyword of the statement, e.g. "SELECT", "CREATE". */ /** The leading keyword of the statement, e.g. "SELECT", "CREATE". */
@@ -45,20 +93,17 @@ export interface MssqlConn {
*/ */
export const READER = "mesh_mssql_reader"; 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. */ /** Who a session logs in as. */
interface Invocation { interface Invocation {
readonly user: string; readonly user: string;
readonly password: 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 { export class MssqlClient {
constructor(private readonly conn: MssqlConn) {} constructor(
private readonly conn: MssqlConn,
private readonly connect: Connect = connectWithDriver,
) {}
/** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */ /** The reader is made once per process: idempotent, and repeating it re-sets a rotated password. */
private readerReady?: Promise<void>; private readerReady?: Promise<void>;
@@ -90,63 +135,41 @@ export class MssqlClient {
return this.conn.port; return this.conn.port;
} }
/** /** Execute a batch that returns no rows (DDL and the like). A failed statement rejects. */
* Execute a batch that returns no rows (DDL and the like), through `sqlcmd`. The password is async exec(text: string, database = "master"): Promise<void> {
* passed by SQLCMDPASSWORD, never on argv, the way postgres passes PGPASSWORD; `-b` makes a await this.session(text, database);
* failed statement an error here rather than a success with a warning, and `-C` trusts the
* server's self-signed certificate the mssql image ships with.
*/
async exec(sql: string, database = "master"): Promise<void> {
await this.sqlcmd(sql, database);
} }
/** /**
* Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is * Run a SELECT and return its rows as objects. The caller's SQL must be a single SELECT; it is
* wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document sqlcmd * wrapped so SQL Server renders the result with `FOR JSON PATH`, and the JSON document it answers
* prints (split across output lines for a large result, and reassembled here) is parsed. An * (split across rows for a large result, and reassembled here) is parsed. An empty result yields
* empty result yields no output at all — an empty array. * no rows — an empty array. `params` are bound as `@name`, never written into the text.
*/ */
async query( async query(
select: string, select: string,
database = "master", database = "master",
variables: Record<string, string> = {}, params: Record<string, string> = {},
): Promise<Record<string, unknown>[]> { ): Promise<Record<string, unknown>[]> {
const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`; const wrapped = `SET NOCOUNT ON;\n${stripTrailingSemis(select)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`;
const stdout = await this.sqlcmd(wrapped, database, variables); return parseJsonRows(await this.session(wrapped, database, params));
return parseJsonRows(stdout);
} }
/** The one execution boundary: invoke `sqlcmd` and return its concatenated stdout. */ /** The one execution boundary: open a session as `as`, run `text`, close it. */
private async sqlcmd( private async session(
sql: string, text: string,
database: string, database: string,
variables: Record<string, string> = {}, params: Record<string, string> = {},
as: Invocation = { user: this.conn.user, password: this.conn.password, caller: false }, as: Invocation = { user: this.conn.user, password: this.conn.password },
): Promise<string> { ): Promise<Record<string, unknown>[]> {
// `-h -1` drops the column-header rule; `-y 0`/`-Y 0` lift the display-width cap so a long const session = await this.connect({
// JSON document is not truncated; `-W` trims trailing whitespace so the JSON chunks rejoin host: this.conn.host, port: this.conn.port, user: as.user, password: as.password, database,
// cleanly. sqlcmd from the mssql-tools ships in the runtime container, the way `psql` ships });
// with postgres's — the module owns its own code (ADR 0039) and shells out to it. try {
const { stdout } = await run( return await session.run(text, params);
"sqlcmd", } finally {
[ await session.close();
"-S", `${this.conn.host},${this.conn.port}`, }
"-U", as.user,
"-d", database,
...(as.caller ? ["-x"] : []),
"-C",
"-b",
"-h", "-1",
"-y", "0",
"-Y", "0",
"-W",
"-Q", sql,
],
// `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: as.password }, maxBuffer: 16 << 20 },
);
return stdout;
} }
/** /**
@@ -173,7 +196,7 @@ export class MssqlClient {
`SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`, `SELECT 1 AS ok FROM sys.databases WHERE name = ${literal(database)}`,
); );
if (dbs.length === 0) { if (dbs.length === 0) {
// CREATE DATABASE must stand alone in its batch; it runs as its own sqlcmd invocation. // CREATE DATABASE must stand alone in its batch; it runs as its own session.
await this.exec(`CREATE DATABASE ${ident(database)}`); await this.exec(`CREATE DATABASE ${ident(database)}`);
} }
@@ -206,15 +229,14 @@ export class MssqlClient {
* nothing logs in and no failed-login is recorded (novox/hq issue 120). * nothing logs in and no failed-login is recorded (novox/hq issue 120).
*/ */
async holdsLogin(database: string, login: string, password: string): Promise<boolean> { async holdsLogin(database: string, login: string, password: string): Promise<boolean> {
// The password reaches sqlcmd as a scripting variable from the environment, never inside the // The password is a bound parameter, never inside the query text, so it is in no message of a
// query text, so it is neither on argv nor in the message of a failed command. It is the mesh's // failed statement.
// minted value, which carries no quote.
const server = await this.query( const server = await this.query(
`SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` + `SELECT CAST(CASE WHEN EXISTS (SELECT 1 FROM sys.sql_logins WHERE name = ${literal(login)} ` +
`AND is_disabled = 0 AND PWDCOMPARE(N'$(MESHHOLDSPW)', password_hash) = 1) ` + `AND is_disabled = 0 AND PWDCOMPARE(@meshholdspw, password_hash) = 1) ` +
`AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`, `AND DB_ID(${literal(database)}) IS NOT NULL THEN 1 ELSE 0 END AS int) AS ok`,
"master", "master",
{ MESHHOLDSPW: password }, { meshholdspw: password },
); );
if (Number(server[0]?.ok) !== 1) return false; if (Number(server[0]?.ok) !== 1) return false;
// The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the // The user must be this login's, by SID, and a db_owner. A user orphaned by a restore has the
@@ -294,35 +316,27 @@ export class MssqlClient {
* (novox/hq issue 193). Read-only by the login, not by a transaction wrapped around the text; the * (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 * rows are rendered by FOR JSON. Never as the administrator: without the reader's password the call
* is refused. * is refused.
*
* The text goes to the server as it is, over the driver: there is no client between that reads a
* line of its own (sqlcmd's `:!!`, which could start a program) or substitutes `$(NAME)` from this
* process's environment, so neither the one-line rule nor `-x` has anything left to guard.
*/ */
async readOnlyQuery(database: string, sql: string): Promise<QueryResult> { async readOnlyQuery(database: string, text: string): Promise<QueryResult> {
const password = this.conn.readerPassword; const password = this.conn.readerPassword;
if (!password) throw readerMissing(); 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 ??= this.ensureReader().catch((err) => {
this.readerReady = undefined; // asked again next call, not failed for the process's life this.readerReady = undefined; // asked again next call, not failed for the process's life
throw err; throw err;
}); });
await this.readerReady; await this.readerReady;
const stdout = await this.sqlcmd( const rows = await this.session(
`SET NOCOUNT ON; ${stripTrailingSemis(sql)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`, `SET NOCOUNT ON; ${stripTrailingSemis(text)}\nFOR JSON PATH, INCLUDE_NULL_VALUES;`,
database, database,
{}, {},
{ user: READER, password, caller: true }, { user: READER, password },
); );
const command = /^\s*([A-Za-z]+)/.exec(sql)?.[1]?.toUpperCase() ?? ""; const command = /^\s*([A-Za-z]+)/.exec(text)?.[1]?.toUpperCase() ?? "";
return { command, rows: parseJsonRows(stdout) }; return { command, rows: parseJsonRows(rows) };
} }
} }
@@ -372,18 +386,13 @@ function safeUrl(raw: string): URL | undefined {
} }
/** /**
* Parse the JSON a FOR JSON query prints through sqlcmd. SQL Server splits a large FOR JSON result * Parse the JSON a FOR JSON query answers. SQL Server splits a large FOR JSON result into
* into ~2033-character chunks, one per output row; with `-h -1 -W` each lands on its own line, so * ~2033-character chunks, one per row of a single column, so the document is reassembled by
* the document is reassembled by concatenating the non-empty lines. No output (an empty result, or * concatenating that column in order. No rows (an empty result, or a pure DDL batch) means none.
* a pure DDL batch) means no rows.
*/ */
function parseJsonRows(stdout: string): Record<string, unknown>[] { function parseJsonRows(rows: Record<string, unknown>[]): Record<string, unknown>[] {
const joined = stdout const joined = rows.map((row) => String(Object.values(row)[0] ?? "")).join("");
.split(/\r?\n/) if (joined.trim().length === 0) return [];
.map((l) => l.trimEnd())
.filter((l) => l.length > 0)
.join("");
if (joined.length === 0) return [];
const parsed = JSON.parse(joined); const parsed = JSON.parse(joined);
return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>]; return Array.isArray(parsed) ? (parsed as Record<string, unknown>[]) : [parsed as Record<string, unknown>];
} }
+3 -3
View File
@@ -1,9 +1,9 @@
// mssql's events entrypoint, loaded by the per-node tool host (the provisioner container runs // mssql's events entrypoint, launched by the node's runtime beside its tools and provisioner
// ./provisioner separately). The database lifecycle events are EMITTED from the provisioner, where // (novox/hq ADR 0198). The database lifecycle events are EMITTED from the provisioner, where
// the lifecycle actually happens (novox/hq ADR 0041/0042): // the lifecycle actually happens (novox/hq ADR 0041/0042):
// module.mssql.database.provisioned — a consumer's database + login/user was created // module.mssql.database.provisioned — a consumer's database + login/user was created
// module.mssql.database.deprovisioned — that database was removed // module.mssql.database.deprovisioned — that database was removed
// Here in the tool host we react to them, keeping a lightweight audit trail of who was granted a // Here in the runtime we react to them, keeping a lightweight audit trail of who was granted a
// database and who lost one — observability the provider itself is best placed to log. // database and who lost one — observability the provider itself is best placed to log.
import { on } from "@novox/mesh-sdk/events"; import { on } from "@novox/mesh-sdk/events";
+19 -42
View File
@@ -38,16 +38,9 @@
}, },
"own-secrets": { "own-secrets": {
"sa": "${dir:state}/sa.secret", "sa": "${dir:state}/sa.secret",
"broker": "${dir:mesh-state}/broker",
"reader": "${dir:state}/reader.secret" "reader": "${dir:state}/reader.secret"
}, },
"resources": [ "resources": [
{
"id": "mesh-state",
"type": "directory",
"mode": "0700",
"place": "mesh"
},
{ {
"id": "state", "id": "state",
"type": "directory", "type": "directory",
@@ -93,46 +86,30 @@
"${dir:data}:/var/opt/mssql" "${dir:data}:/var/opt/mssql"
], ],
"secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint" "secrets-in-environment": "the image documents only MSSQL_SA_PASSWORD, no _FILE and no configuration field; not convertible without a wrapper entrypoint"
},
{
"id": "runtime",
"type": "container",
"name": "mesh-mssql",
"network": "mssql",
"volumes": [
"${dir:mesh-state}/broker:/run/secrets/broker:ro",
"${dir:grants}:/var/lib/mssql/grants:ro",
"${dir:state}/sa.secret:/run/secrets/sa:ro",
"${dir:state}/reader.secret:/run/secrets/reader:ro"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@mssql:1433/master",
"MESH_PROVISION_PASSWORD_FILE": "/run/secrets/sa",
"MESH_BROKER_FILE": "/run/secrets/broker",
"MESH_RECEIVES": "/var/lib/mssql/grants/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "/run/secrets/reader"
},
"artifact": "runtime"
} }
], ],
"build": { "build": {
"on": [
{
"arg": "BUILD_BASE",
"module": "mesh-tools",
"artifact": "build"
},
{
"arg": "RUNTIME_BASE",
"module": "mesh-tools",
"artifact": "runtime"
}
],
"artifacts": [ "artifacts": [
{ {
"name": "runtime", "name": "code",
"kind": "image", "kind": "bundle",
"from": "Dockerfile" "language": "typescript",
"entrypoints": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"loads": [
"index.js",
"tools/index.js",
"provisioner/index.js"
],
"env": {
"MESH_PROVISION_MSSQL": "mssql://sa@127.0.0.1:${port:1433}/master",
"MESH_PROVISION_PASSWORD_FILE": "${dir:state}/sa.secret",
"MESH_RECEIVES": "${dir:grants}/mesh.json",
"MESH_MSSQL_READER_PASSWORD_FILE": "${dir:state}/reader.secret"
}
} }
] ]
} }
+32
View File
@@ -0,0 +1,32 @@
// Ambient types for `mssql`, which ships its types only in the separate `@types/mssql` package. This
// declares the slice client.ts uses — the precedent mesh-catalog's pg.d.ts sets — so the module
// type-checks without deciding what runs: the real `mssql` is the package.json dependency the
// builder installs and inlines into the bundle (novox/hq ADR 0198 §4).
declare module "mssql" {
interface Result {
recordsets: unknown;
}
interface Request {
input(name: string, type: unknown, value: unknown): Request;
query(text: string): Promise<Result>;
batch(text: string): Promise<Result>;
}
class ConnectionPool {
constructor(config: {
server: string;
port?: number;
user?: string;
password?: string;
database?: string;
options?: { encrypt?: boolean; trustServerCertificate?: boolean };
pool?: { min?: number; max?: number };
connectionTimeout?: number;
requestTimeout?: number;
});
connect(): Promise<ConnectionPool>;
request(): Request;
close(): Promise<void>;
}
const sql: { ConnectionPool: typeof ConnectionPool; NVarChar: unknown };
export default sql;
}
+3 -2
View File
@@ -5,11 +5,12 @@
"type": "module", "type": "module",
"private": true, "private": true,
"scripts": { "scripts": {
"build": "tsc client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist", "build": "tsc mssql.d.ts client.ts index.ts tools/index.ts provisioner/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --outDir dist",
"test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'" "test": "npm run build && node --test --experimental-strip-types 'test/*.test.ts'"
}, },
"dependencies": { "dependencies": {
"@novox/mesh-sdk": "^0.1.1" "@novox/mesh-sdk": "^0.1.1",
"mssql": "^11.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.0.0", "@types/node": "^22.0.0",
+50 -63
View File
@@ -1,96 +1,83 @@
// What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the // What holds mssql_query to being read-only (novox/hq issue 193): a caller's statement runs as the
// reader login and never as the administrator, with sqlcmd's variable substitution off, on one line // reader login and never as the administrator, with no transaction wrapped around it as text, and
// that follows the module's own — a line break is refused before sqlcmd starts — and with no // without the reader's password the statement is refused.
// transaction wrapped around it as text. Without the reader's password the statement is refused.
// //
// sqlcmd is a fake on PATH that records each call's login, flags and text. That the reader cannot // The driver is a fake session that records each call's login, database, text and bound
// write is the server's to enforce and was proven against a real server; this holds the module to // parameters. That the reader cannot write is the server's to enforce and was proven against a real
// asking for it. Run against the compiled module (npm test builds first), the way the runtime loads it. // server; this holds the module to asking for it. Run against the compiled module (npm test builds
// first), the way the runtime loads it.
import { test, before, after } from "node:test"; import { test } from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import { chmod, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { MssqlClient, READER } from "../dist/client.js"; import { MssqlClient, READER, type Connect, type Target } from "../dist/client.js";
let dir: string; interface Call extends Target {
let log: string; text: string;
const originalPath = process.env.PATH; params: Record<string, string>;
}
before(async () => { function recording(): { connect: Connect; calls: Call[] } {
dir = await mkdtemp(join(tmpdir(), "mssql-reader-")); const calls: Call[] = [];
log = join(dir, "calls.jsonl"); const connect: Connect = async (to) => ({
await writeFile(join(dir, "sqlcmd"), `#!/usr/bin/env node async run(text, params = {}) {
const fs = require("node:fs"); calls.push({ ...to, text, params });
const args = process.argv.slice(2); if (/FROM sys.server_principals/.test(text)) return [];
const at = (flag) => args[args.indexOf(flag) + 1]; // FOR JSON answers its document split across rows of one column.
fs.appendFileSync(${JSON.stringify(log)}, JSON.stringify({ if (/FOR JSON/.test(text)) return [{ JSON_F52E: '[{"name":"al' }, { JSON_F52E: 'pha","n":1}]' }];
user: at("-U"), database: at("-d"), sql: at("-Q"), noVariables: args.includes("-x"), return [];
password: process.env.SQLCMDPASSWORD, },
}) + "\\n"); async close() {},
const sql = at("-Q");
if (/FROM sys.server_principals/.test(sql)) process.stdout.write("");
else if (/FOR JSON/.test(sql)) process.stdout.write('[{"name":"alpha","n":1}]\\n');
`);
await chmod(join(dir, "sqlcmd"), 0o755);
process.env.PATH = `${dir}:${originalPath}`;
}); });
return { connect, calls };
after(async () => {
process.env.PATH = originalPath;
await rm(dir, { recursive: true, force: true });
});
async function calls(): Promise<Record<string, unknown>[]> {
const text = await readFile(log, "utf8").catch(() => "");
await writeFile(log, "");
return text.split("\n").filter(Boolean).map((line) => JSON.parse(line));
} }
const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" }; const conn = { host: "127.0.0.1", port: 1433, user: "sa", password: "admin-secret" };
test("a caller's statement runs as the reader, without variables, on the module's first line", async () => { test("a caller's statement runs as the reader, as it was written, on the database it names", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p"); const result = await client.readOnlyQuery("inventory", "SELECT '$(SQLCMDPASSWORD)' AS p");
const asked = (await calls()).at(-1)!; const asked = calls.at(-1)!;
assert.equal(asked.user, READER, "the statement never runs as the administrator"); assert.equal(asked.user, READER, "the statement never runs as the administrator");
assert.equal(asked.password, "reader-secret"); assert.equal(asked.password, "reader-secret");
assert.equal(asked.noVariables, true, "no $(NAME) is substituted in a caller's text"); assert.equal(asked.database, "inventory");
const [first] = String(asked.sql).split("\n"); assert.ok(asked.text.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)' AS p\nFOR JSON PATH"),
assert.ok(first.startsWith("SET NOCOUNT ON; SELECT '$(SQLCMDPASSWORD)'"), "the caller's text never begins a line"); "the caller's text reaches the server unaltered");
assert.doesNotMatch(String(asked.sql), /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text"); assert.doesNotMatch(asked.text, /BEGIN TRANSACTION|ROLLBACK/, "no transaction wrapped around it as text");
assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }]); assert.deepEqual(result.rows, [{ name: "alpha", n: 1 }], "a FOR JSON document split across rows is reassembled");
assert.equal(result.command, "SELECT"); assert.equal(result.command, "SELECT");
}); });
test("a line break in a caller's statement is refused before sqlcmd starts", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" });
for (const sql of ["SELECT 1\n:!! id", "SELECT 1\r\n:!! id", "SELECT 1\r:!! id"]) {
await assert.rejects(client.readOnlyQuery("inventory", sql), /must be one line/);
}
assert.deepEqual(await calls(), []);
});
test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => { test("the reader is made as the administrator, kept out of sysadmin, and granted only reading", async () => {
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }); const { connect, calls } = recording();
const client = new MssqlClient({ ...conn, readerPassword: "reader-secret" }, connect);
await client.readOnlyQuery("inventory", "SELECT 1 AS x"); await client.readOnlyQuery("inventory", "SELECT 1 AS x");
await client.readOnlyQuery("inventory", "SELECT 2 AS x"); await client.readOnlyQuery("inventory", "SELECT 2 AS x");
const made = await calls(); const asAdmin = calls.filter((c) => c.user === "sa").map((c) => c.text);
const asAdmin = made.filter((c) => c.user === "sa").map((c) => String(c.sql));
assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`))); assert.ok(asAdmin.some((s) => s.startsWith(`CREATE LOGIN [${READER}]`)));
assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s))); assert.ok(asAdmin.some((s) => /ALTER SERVER ROLE sysadmin DROP MEMBER/.test(s)));
assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT CONNECT ANY DATABASE TO [${READER}]`));
assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`)); assert.ok(asAdmin.includes(`GRANT SELECT ALL USER SECURABLES TO [${READER}]`));
assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call"); assert.equal(asAdmin.filter((s) => s.startsWith("CREATE LOGIN")).length, 1, "made once, not per call");
assert.equal(made.filter((c) => c.user === READER).length, 2); assert.equal(calls.filter((c) => c.user === READER).length, 2);
}); });
test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => { test("without the reader's password the statement is refused, and nothing runs as the administrator", async () => {
const client = new MssqlClient(conn); const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/); await assert.rejects(client.readOnlyQuery("inventory", "SELECT 1"), /refused rather than run as the administrator/);
assert.deepEqual(await calls(), []); assert.deepEqual(calls, []);
});
test("a consumer's password is checked as a bound parameter, never in the text", async () => {
const { connect, calls } = recording();
const client = new MssqlClient(conn, connect);
await client.holdsLogin("shop", "shop_login", "minted-secret");
const asked = calls[0];
assert.equal(asked.params.meshholdspw, "minted-secret");
assert.doesNotMatch(asked.text, /minted-secret/);
assert.match(asked.text, /PWDCOMPARE\(@meshholdspw, password_hash\)/);
}); });
+1 -1
View File
@@ -8,5 +8,5 @@
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true "noEmit": true
}, },
"include": ["client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"] "include": ["mssql.d.ts", "client.ts", "index.ts", "provisioner/index.ts", "tools/index.ts"]
} }