feat(esmole): readonly request guard

This commit is contained in:
admin committed 2026-06-16 20:46:23 +05:00
1 parent a424963418
commit 7381db1189
2 files changed
+105

No files matched your search

+54
View File
@@ -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')
}
@@ -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)
})
})