From 7381db11899c934af9ad65f1ab12e8fad5fffca4 Mon Sep 17 00:00:00 2001 From: Evgeny Gamov Date: Tue, 16 Jun 2026 20:46:23 +0500 Subject: [PATCH] feat(esmole): readonly request guard --- packages/esmole-mcp/src/es/guard.ts | 54 +++++++++++++++++++ .../esmole-mcp/test/unit/es/guard.test.ts | 51 ++++++++++++++++++ 2 files changed, 105 insertions(+) create mode 100644 packages/esmole-mcp/src/es/guard.ts create mode 100644 packages/esmole-mcp/test/unit/es/guard.test.ts diff --git a/packages/esmole-mcp/src/es/guard.ts b/packages/esmole-mcp/src/es/guard.ts new file mode 100644 index 0000000..02cac39 --- /dev/null +++ b/packages/esmole-mcp/src/es/guard.ts @@ -0,0 +1,54 @@ +export class EsGuardError extends Error { + constructor(message: string) { + super(message) + this.name = 'EsGuardError' + } +} + +// POST is allowed in readonly mode only when the path ends in one of these +// read-intent suffixes. Matched against the path with its query string and +// trailing slash stripped. +const READ_POST_SUFFIXES = [ + '_search', + '_msearch', + '_count', + '_field_caps', + '_mapping', + '_search/scroll', + '_pit' +] + +// DELETE is allowed in readonly mode only for read-session teardown. +const READ_DELETE_PATHS = ['_pit', '_search/scroll'] + +const normalize = (path: string): string => { + const noQuery = path.split('?')[0] + return noQuery.endsWith('/') ? noQuery.slice(0, -1) : noQuery +} + +const endsWithSegment = (path: string, suffix: string): boolean => + path === '/' + suffix || path.endsWith('/' + suffix) + +export const assertReadAllowed = (method: string, path: string): void => { + const m = method.toUpperCase() + const p = normalize(path) + if (m === 'GET' || m === 'HEAD') { + return + } + if (m === 'POST') { + if ( + p.includes('/_cat/') || + p.endsWith('/_cat') || + READ_POST_SUFFIXES.some((s) => endsWithSegment(p, s)) + ) { + return + } + throw new EsGuardError( + "readonly connection: POST to '" + p + "' is not a permitted read operation" + ) + } + if (m === 'DELETE' && READ_DELETE_PATHS.some((s) => endsWithSegment(p, s))) { + return + } + throw new EsGuardError('readonly connection: ' + m + ' is not permitted') +} diff --git a/packages/esmole-mcp/test/unit/es/guard.test.ts b/packages/esmole-mcp/test/unit/es/guard.test.ts new file mode 100644 index 0000000..fec243d --- /dev/null +++ b/packages/esmole-mcp/test/unit/es/guard.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { assertReadAllowed, EsGuardError } from '../../../src/es/guard.js' + +describe('assertReadAllowed', () => { + it('allows GET and HEAD anywhere', () => { + expect(() => assertReadAllowed('GET', '/idx/_doc/1')).not.toThrow() + expect(() => assertReadAllowed('HEAD', '/idx')).not.toThrow() + }) + it('allows POST to read endpoints', () => { + for (const p of [ + '/idx/_search', + '/_msearch', + '/idx/_count', + '/_field_caps', + '/_cat/indices', + '/idx/_mapping', + '/_search/scroll', + '/idx/_pit' + ]) { + expect(() => assertReadAllowed('POST', p), p).not.toThrow() + } + }) + it('blocks POST to write endpoints', () => { + for (const p of [ + '/idx/_doc', + '/idx/_bulk', + '/idx/_update/1', + '/idx/_delete_by_query', + '/_sql' + ]) { + expect(() => assertReadAllowed('POST', p), p).toThrow(EsGuardError) + } + }) + it('blocks PUT and generic DELETE', () => { + expect(() => assertReadAllowed('PUT', '/idx')).toThrow(EsGuardError) + expect(() => assertReadAllowed('DELETE', '/idx/_doc/1')).toThrow(EsGuardError) + }) + it('allows DELETE only for pit and scroll cleanup', () => { + expect(() => assertReadAllowed('DELETE', '/_pit')).not.toThrow() + expect(() => assertReadAllowed('DELETE', '/_search/scroll')).not.toThrow() + }) + it('ignores query strings when matching', () => { + expect(() => assertReadAllowed('POST', '/idx/_search?scroll=1m')).not.toThrow() + }) + it('ignores a trailing slash', () => { + expect(() => assertReadAllowed('POST', '/idx/_search/')).not.toThrow() + }) + it('rejects an unknown method', () => { + expect(() => assertReadAllowed('PATCH', '/idx/_search')).toThrow(EsGuardError) + }) +})