fix(elasticsearch): treat rest reads as read-only
This commit is contained in:
1 parent
2fe26052a7
commit
d411f04975
4 files changed
+218
-1
No files matched your search
@@ -0,0 +1,67 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { classifySqlRisk, classifySqlStatementRisk } from "@/lib/sql/sqlRisk";
|
||||
import { sqlLooksLikeMutation } from "@/lib/database/readOnlyWriteAccess";
|
||||
|
||||
const SEARCH_REQUEST = 'POST /demo/_search\n{\n "from": 0,\n "size": 100,\n "sort": [\n "_doc"\n ]\n}';
|
||||
|
||||
describe("Elasticsearch request risk", () => {
|
||||
it("treats searches and other retrieval requests as reads", () => {
|
||||
for (const dialect of ["elasticsearch", "easysearch"] as const) {
|
||||
expect(classifySqlRisk(SEARCH_REQUEST, { dialect }).risk).toBe("read");
|
||||
expect(classifySqlRisk("GET /demo/_doc/1", { dialect }).risk).toBe("read");
|
||||
expect(classifySqlRisk("GET /_cat/indices?format=json", { dialect }).risk).toBe("read");
|
||||
expect(classifySqlRisk("HEAD /demo", { dialect }).risk).toBe("read");
|
||||
expect(classifySqlRisk('POST /demo/_count\n{"query":{"match_all":{}}}', { dialect }).risk).toBe("read");
|
||||
expect(classifySqlRisk('POST /_sql\n{"query":"SELECT * FROM demo"}', { dialect }).risk).toBe("read");
|
||||
}
|
||||
});
|
||||
|
||||
it("treats document changes as writes and index changes as DDL", () => {
|
||||
expect(classifySqlRisk('POST /demo/_doc\n{"a":1}', { dialect: "elasticsearch" }).risk).toBe("write");
|
||||
expect(classifySqlRisk('PUT /demo/_doc/1\n{"a":1}', { dialect: "elasticsearch" }).risk).toBe("write");
|
||||
expect(classifySqlRisk("DELETE /demo/_doc/1", { dialect: "elasticsearch" }).risk).toBe("write");
|
||||
expect(classifySqlRisk("DELETE /demo", { dialect: "elasticsearch" }).risk).toBe("ddl");
|
||||
expect(classifySqlRisk('PUT /demo\n{"settings":{}}', { dialect: "elasticsearch" }).risk).toBe("ddl");
|
||||
expect(classifySqlRisk('POST /demo/_delete_by_query\n{"query":{"match_all":{}}}', { dialect: "elasticsearch" }).risk).toBe("ddl");
|
||||
});
|
||||
|
||||
it("reports the highest risk across every request in the editor text", () => {
|
||||
const source = `${SEARCH_REQUEST}\n\nDELETE /demo`;
|
||||
expect(classifySqlRisk(source, { dialect: "elasticsearch" }).risk).toBe("ddl");
|
||||
expect(classifySqlStatementRisk(SEARCH_REQUEST, { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
});
|
||||
|
||||
it("reads the request line through query strings, trailing slashes and CRLF endings", () => {
|
||||
expect(classifySqlRisk("GET /demo/_search?size=1", { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
expect(classifySqlRisk("post /demo/_search/\r\n{}", { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
expect(classifySqlRisk("DELETE /demo/_doc/1?refresh=true", { dialect: "elasticsearch" }).risk).toBe("write");
|
||||
expect(classifySqlRisk(" GET /demo/_search // inline comment", { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
});
|
||||
|
||||
it("ignores commented-out requests", () => {
|
||||
expect(classifySqlRisk(`# DELETE /demo\n// DELETE /demo\n/* DELETE /demo */\n${SEARCH_REQUEST}`, { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
expect(classifySqlRisk("/*\nDELETE /demo\n*/\nGET /demo/_search", { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
});
|
||||
|
||||
it("still classifies Elasticsearch SQL through the SQL rules", () => {
|
||||
expect(classifySqlRisk("SELECT * FROM demo", { dialect: "elasticsearch" }).risk).toBe("read");
|
||||
expect(classifySqlRisk("DROP TABLE demo", { dialect: "elasticsearch" }).risk).toBe("ddl");
|
||||
});
|
||||
|
||||
it("leaves text that does not start with a request line to the SQL rules", () => {
|
||||
// The backend anchors on the first line too, so a stray request line further
|
||||
// down must not turn unparseable text into a read.
|
||||
expect(classifySqlRisk('{"query":{"match_all":{}}}\nGET /demo/_search', { dialect: "elasticsearch" }).risk).toBe("unknown");
|
||||
});
|
||||
|
||||
it("keeps REST requests classified as SQL for other database types", () => {
|
||||
expect(classifySqlRisk(SEARCH_REQUEST, { dialect: "mysql" }).risk).toBe("unknown");
|
||||
});
|
||||
|
||||
it("does not ask a read-only connection to unlock writes for a search", () => {
|
||||
expect(sqlLooksLikeMutation(SEARCH_REQUEST, "elasticsearch")).toBe(false);
|
||||
expect(sqlLooksLikeMutation("GET /demo/_search", "easysearch")).toBe(false);
|
||||
expect(sqlLooksLikeMutation("DELETE /demo", "elasticsearch")).toBe(true);
|
||||
expect(sqlLooksLikeMutation('POST /demo/_doc\n{"a":1}', "elasticsearch")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -94,6 +94,20 @@ describe("read-only write unlock", () => {
|
||||
expect(connectionWriteUnlockState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not prompt for Elasticsearch searches on a read-only connection", async () => {
|
||||
const store = useReadOnlyUnlockStore();
|
||||
const connection = { id: "es", name: "es-prod", read_only: true, db_type: "elasticsearch" as const };
|
||||
|
||||
await expect(ensureReadOnlyWriteAccess({ connection, sql: 'POST /demo/_search\n{\n "size": 100\n}' })).resolves.toBe(true);
|
||||
await expect(ensureReadOnlyWriteAccess({ connection, sql: "GET /demo/_doc/1" })).resolves.toBe(true);
|
||||
expect(store.pending).toBeUndefined();
|
||||
|
||||
const pending = ensureReadOnlyWriteAccess({ connection, sql: "DELETE /demo" });
|
||||
await waitForPending("es");
|
||||
store.cancel();
|
||||
await expect(pending).resolves.toBe(false);
|
||||
});
|
||||
|
||||
it("reuses an existing backend window without prompting again", async () => {
|
||||
connectionWriteUnlockState.mockResolvedValue(45_000);
|
||||
const connection = { id: "prod", name: "prod-db", read_only: true, db_type: "mysql" as const };
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Read/write classification for Elasticsearch-compatible REST requests.
|
||||
*
|
||||
* Mirrors `classify_search_engine_query_risk` in
|
||||
* `crates/dbx-core/src/query_execution_sql.rs` so the desktop guards (read-only
|
||||
* unlock, production safety) agree with the backend read-only gate instead of
|
||||
* treating every `GET`/`POST` request as an unrecognized — and therefore
|
||||
* unsafe — statement.
|
||||
*/
|
||||
|
||||
export type ElasticsearchRequestRisk = "read" | "write" | "dangerous";
|
||||
|
||||
const REQUEST_LINE = /^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S+)/i;
|
||||
const READ_ONLY_POST_ENDPOINTS = ["_search", "_count", "_sql", "_msearch", "_field_caps", "_terms_enum", "_validate", "_explain", "_rank_eval", "_search_shards"];
|
||||
const WRITE_POST_ENDPOINTS = ["_doc", "_create", "_update", "_bulk"];
|
||||
const RISK_ORDER: Record<ElasticsearchRequestRisk, number> = { read: 0, write: 1, dangerous: 2 };
|
||||
|
||||
interface ParsedRequestLine {
|
||||
method: string;
|
||||
segments: string[];
|
||||
}
|
||||
|
||||
function parseRequestLine(line: string): ParsedRequestLine | null {
|
||||
const match = line.trim().match(REQUEST_LINE);
|
||||
if (!match) return null;
|
||||
const path = (match[2].split("?", 1)[0] ?? "").replace(/\/+$/, "");
|
||||
return { method: match[1].toUpperCase(), segments: path.split("/").filter(Boolean) };
|
||||
}
|
||||
|
||||
function classifyParsedRequest({ method, segments }: ParsedRequestLine): ElasticsearchRequestRisk {
|
||||
const hasSegment = (candidate: string) => segments.some((segment) => segment.toLowerCase() === candidate);
|
||||
const hasDocumentId = (candidate: string) => {
|
||||
const index = segments.findIndex((segment) => segment.toLowerCase() === candidate);
|
||||
return index >= 0 && !!segments[index + 1];
|
||||
};
|
||||
|
||||
if (method === "GET" || method === "HEAD" || method === "OPTIONS") return "read";
|
||||
if (method === "POST") {
|
||||
if (READ_ONLY_POST_ENDPOINTS.some((endpoint) => hasSegment(endpoint))) return "read";
|
||||
return WRITE_POST_ENDPOINTS.some((endpoint) => hasSegment(endpoint)) ? "write" : "dangerous";
|
||||
}
|
||||
if (method === "PUT" && (hasDocumentId("_doc") || hasDocumentId("_create"))) return "write";
|
||||
if (method === "DELETE" && hasDocumentId("_doc")) return "write";
|
||||
return "dangerous";
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a single request: the request line is the first non-comment line,
|
||||
* everything after it is the JSON/NDJSON body. Returns null when the text is
|
||||
* not a REST request — Elasticsearch also accepts SQL, classified elsewhere.
|
||||
*/
|
||||
export function classifyElasticsearchRequestRisk(request: string): ElasticsearchRequestRisk | null {
|
||||
const parsed = parseRequestLine(uncommentedLines(request)[0] ?? "");
|
||||
return parsed ? classifyParsedRequest(parsed) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies every request in the editor text and returns the highest risk, so
|
||||
* a read request followed by a write is still guarded. Like the backend, the
|
||||
* text only counts as REST when its first non-comment line is a request line;
|
||||
* anything else is left to the SQL classification.
|
||||
*/
|
||||
export function classifyElasticsearchSourceRisk(source: string): ElasticsearchRequestRisk | null {
|
||||
const lines = uncommentedLines(source);
|
||||
if (!parseRequestLine(lines[0] ?? "")) return null;
|
||||
|
||||
let highest: ElasticsearchRequestRisk = "read";
|
||||
for (const line of lines) {
|
||||
const parsed = parseRequestLine(line);
|
||||
if (!parsed) continue;
|
||||
const risk = classifyParsedRequest(parsed);
|
||||
if (RISK_ORDER[risk] > RISK_ORDER[highest]) highest = risk;
|
||||
}
|
||||
return highest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns each line with its leading comments removed, dropping lines that hold
|
||||
* nothing else. Block comments spanning several lines are skipped as a whole so
|
||||
* a commented-out request line is never classified.
|
||||
*/
|
||||
function uncommentedLines(source: string): string[] {
|
||||
const lines: string[] = [];
|
||||
let inBlockComment = false;
|
||||
|
||||
for (const rawLine of source.split("\n")) {
|
||||
let offset = 0;
|
||||
while (offset < rawLine.length) {
|
||||
if (inBlockComment) {
|
||||
const close = rawLine.indexOf("*/", offset);
|
||||
if (close < 0) break;
|
||||
inBlockComment = false;
|
||||
offset = close + 2;
|
||||
continue;
|
||||
}
|
||||
while (offset < rawLine.length && /\s/.test(rawLine[offset] ?? "")) offset += 1;
|
||||
if (offset >= rawLine.length) break;
|
||||
if (rawLine.startsWith("/*", offset)) {
|
||||
inBlockComment = true;
|
||||
offset += 2;
|
||||
continue;
|
||||
}
|
||||
if (rawLine[offset] === "#" || rawLine.startsWith("//", offset)) break;
|
||||
lines.push(rawLine.slice(offset));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return lines;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { DatabaseType } from "@/types/database";
|
||||
import { classifyElasticsearchRequestRisk, classifyElasticsearchSourceRisk, type ElasticsearchRequestRisk } from "@/lib/elasticsearch/elasticsearchRequestRisk";
|
||||
import { isElasticsearchCompatibleDatabaseType, type DatabaseType } from "@/types/database";
|
||||
|
||||
export type SqlRiskLevel = "read" | "write" | "ddl" | "transaction" | "unknown";
|
||||
|
||||
@@ -44,6 +45,11 @@ export function splitSqlStatementsForSafety(sql: string): string[] {
|
||||
}
|
||||
|
||||
export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlRiskAssessment {
|
||||
// REST requests carry a JSON body that must not be split on semicolons, and
|
||||
// every request in the text is classified so the highest risk wins.
|
||||
const searchEngineRisk = searchEngineAssessment(sql, options.dialect, classifyElasticsearchSourceRisk);
|
||||
if (searchEngineRisk) return { ...searchEngineRisk, statements: [searchEngineRisk] };
|
||||
|
||||
const statements = splitSqlStatementsForSafety(sql).map((statement) => classifySqlStatementRisk(statement, options));
|
||||
if (!statements.length) return { risk: "unknown", statements: [] };
|
||||
const highest = statements.reduce<SqlRiskStatementAssessment>((current, statement) => (RISK_ORDER[statement.risk] > RISK_ORDER[current.risk] ? statement : current), { risk: "read" });
|
||||
@@ -53,9 +59,29 @@ export function classifySqlRisk(sql: string, options: SqlRiskOptions = {}): SqlR
|
||||
export function classifySqlStatementRisk(sql: string, options: SqlRiskOptions = {}): SqlRiskStatementAssessment {
|
||||
const dynamodbRisk = classifyDynamoDbStatementRisk(sql, options.dialect);
|
||||
if (dynamodbRisk) return dynamodbRisk;
|
||||
const searchEngineRisk = searchEngineAssessment(sql, options.dialect, classifyElasticsearchRequestRisk);
|
||||
if (searchEngineRisk) return searchEngineRisk;
|
||||
return classifyTokens(tokenizeSqlForRisk(sql));
|
||||
}
|
||||
|
||||
/**
|
||||
* Elasticsearch-compatible connections run REST requests whose method and path
|
||||
* decide the risk; `GET`/`POST _search` reads must not be mistaken for writes.
|
||||
* Text that is not a REST request (Elasticsearch SQL) falls through to the
|
||||
* ordinary SQL classification.
|
||||
*
|
||||
* The HTTP method is deliberately not reported as the first keyword: callers
|
||||
* map that keyword onto SQL semantics (`insert` is a low-risk write, `create`
|
||||
* is a schema change, ...), which a request path does not carry. Reporting
|
||||
* `rest` keeps REST mutations as opaque as they were before this branch existed.
|
||||
*/
|
||||
function searchEngineAssessment(sql: string, dialect: DatabaseType | string | undefined, classify: (value: string) => ElasticsearchRequestRisk | null): SqlRiskStatementAssessment | null {
|
||||
if (!isElasticsearchCompatibleDatabaseType(dialect as DatabaseType | undefined)) return null;
|
||||
const risk = classify(sql);
|
||||
if (!risk) return null;
|
||||
return { risk: risk === "read" ? "read" : risk === "write" ? "write" : "ddl", firstKeyword: "rest" };
|
||||
}
|
||||
|
||||
function classifyDynamoDbStatementRisk(sql: string, dialect?: DatabaseType | string): SqlRiskStatementAssessment | null {
|
||||
if (dialect !== "dynamodb") return null;
|
||||
const header = sql
|
||||
|
||||
Reference in new issue
Block a user