feat(esmole): readonly request guard
This commit is contained in:
1 parent
a424963418
commit
7381db1189
2 files changed
+105
No files matched your search
@@ -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)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in new issue
Block a user