Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions src/lib/__tests__/loki.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,16 @@ describe('validateNamespaceLockdown', () => {
expect(validateNamespaceLockdown('{namespace=`prod`} |= "error"', 'prod')).toBe(true);
});

it('accepts a single-quoted namespace matcher value', () => {
expect(validateNamespaceLockdown("{namespace='prod'} |= \"error\"", 'prod')).toBe(true);
});

it('rejects a mismatched selector even when a single-quoted line filter contains the locked namespace text', () => {
expect(
validateNamespaceLockdown("{namespace=\"evil\"} |= 'namespace=\"prod\"'", 'prod'),
).toBe(false);
});

it('rejects a negated namespace matcher even when the value matches', () => {
expect(validateNamespaceLockdown('{namespace!="prod"} |= "error"', 'prod')).toBe(false);
expect(validateNamespaceLockdown('{namespace!~"prod"} |= "error"', 'prod')).toBe(false);
Expand Down
105 changes: 104 additions & 1 deletion src/lib/__tests__/prometheus.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest';
import { runPrometheusQuery } from '../prometheus.ts';
import { runPrometheusQuery, validateNamespaceLockdown } from '../prometheus.ts';
import type { PrometheusConfig } from '../prometheus.ts';
import { mockFetch, makeAbortError, mockFetchHangsUntilAbort, restoreGlobalsAfterEach } from './test-helpers.ts';

Expand Down Expand Up @@ -120,6 +120,109 @@ describe('runPrometheusQuery — range', () => {
});
});

// ---------------------------------------------------------------------------
// Namespace lockdown
// ---------------------------------------------------------------------------
//
// validateNamespaceLockdown layers a PromQL-specific bare-metric-reference
// scan on top of the shared selector parser in selector-lockdown.ts (see
// loki.test.ts for exhaustive selector/quote-parsing edge cases); these cover
// PromQL-shaped selectors, the bare-reference gap, and runPrometheusQuery
// integration.

describe('validateNamespaceLockdown — PromQL selectors', () => {
it('accepts a vector selector with an exact namespace matcher', () => {
expect(validateNamespaceLockdown('up{namespace="prod"}', 'prod')).toBe(true);
});

it('accepts a single-quoted namespace matcher (PromQL supports single-quoted strings)', () => {
expect(validateNamespaceLockdown("up{namespace='prod'}", 'prod')).toBe(true);
});

it('rejects a bare metric name with no selector', () => {
expect(validateNamespaceLockdown('up', 'prod')).toBe(false);
});

it('rejects a selector targeting a different namespace', () => {
expect(validateNamespaceLockdown('up{namespace="staging"}', 'prod')).toBe(false);
});

it('requires every selector in a multi-selector expression to match', () => {
const query = 'sum(rate(http_requests_total{namespace="prod"}[5m])) / sum(rate(http_requests_total{namespace="evil"}[5m]))';
expect(validateNamespaceLockdown(query, 'prod')).toBe(false);
});

it('accepts a histogram_quantile query using a "by (le)" aggregation modifier', () => {
// Regression: `le` here is a label name in the `by (...)` grouping clause,
// not a bare metric reference, and must not be flagged.
const query = 'histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{namespace="prod"}[5m])) by (le))';
expect(validateNamespaceLockdown(query, 'prod')).toBe(true);
});

it('rejects a bare metric name combined with a locked selector via a binary operator', () => {
// Regression for a real bypass: the extracted selector matches, but the
// bare `up` term has no selector at all and would read every namespace.
expect(validateNamespaceLockdown('up{namespace="prod"} + up', 'prod')).toBe(false);
});

it('rejects a bare metric name combined with a locked selector via a set operator', () => {
expect(
validateNamespaceLockdown('container_memory_usage_bytes or kube_pod_info{namespace="prod"}', 'prod'),
).toBe(false);
});

it('rejects a bare range-vector reference alongside a properly scoped one', () => {
expect(validateNamespaceLockdown('rate(up{namespace="prod"}[5m]) + rate(up[5m])', 'prod')).toBe(false);
});

it('accepts a bare-looking metric name that is entirely covered by its own selector', () => {
expect(validateNamespaceLockdown('sum(rate(a{namespace="prod"}[5m])) / sum(rate(b{namespace="prod"}[5m]))', 'prod')).toBe(true);
});
});
Comment thread
billzhuang marked this conversation as resolved.

describe('runPrometheusQuery — namespace lockdown', () => {
const LOCKED_CONFIG: PrometheusConfig = { ...BASE_CONFIG, lockedNamespace: 'prod' };

it('allows queries whose selector includes the locked namespace', async () => {
const fetchMock = mockFetch('{}');

const result = await runPrometheusQuery('instant', { query: 'up{namespace="prod"}' }, LOCKED_CONFIG);

expect(result).not.toMatch(/BLOCKED/);
expect(fetchMock).toHaveBeenCalledOnce();
});

it('blocks queries with no selector', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const result = await runPrometheusQuery('instant', { query: 'up' }, LOCKED_CONFIG);

expect(result).toMatch(/BLOCKED/);
expect(result).toContain('prod');
expect(fetchMock).not.toHaveBeenCalled();
});

it('blocks queries targeting a different namespace', async () => {
const fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);

const result = await runPrometheusQuery('instant', { query: 'up{namespace="staging"}' }, LOCKED_CONFIG);

expect(result).toMatch(/BLOCKED/);
expect(fetchMock).not.toHaveBeenCalled();
});

it('does not enforce lockdown when lockedNamespace is unset', async () => {
const fetchMock = mockFetch('{}');

const result = await runPrometheusQuery('instant', { query: 'up' }, BASE_CONFIG);

expect(result).not.toMatch(/BLOCKED/);
expect(fetchMock).toHaveBeenCalledOnce();
});
});

// ---------------------------------------------------------------------------
// HTTP errors
// ---------------------------------------------------------------------------
Expand Down
230 changes: 6 additions & 224 deletions src/lib/loki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { resolveTimePassthrough } from './time-resolution.ts';
import { runJsonQuery } from './http.ts';
import { clampLimit } from './tool-config.ts';
import { BLOCKED_PREFIX } from './harness.ts';
import { validateNamespaceSelectorLockdown } from './selector-lockdown.ts';

export interface LokiConfig {
url: string;
Expand All @@ -31,233 +32,14 @@ const truncate = makeTruncate(MAX_RESULT_CHARS, 'use a narrower time range, smal
*/
export const resolveTime = resolveTimePassthrough;

/**
* Strip LogQL `#`-to-end-of-line comments (outside quoted/backtick strings).
* Loki ignores commented text when executing a query, so a decoy stream
* selector hidden in a comment must not be mistaken for the real one.
*/
function stripLogQLComments(query: string): string {
let out = '';
let i = 0;
const n = query.length;
while (i < n) {
const ch = query[i];
if (ch === '"') {
out += ch;
i++;
while (i < n && query[i] !== '"') {
if (query[i] === '\\' && i + 1 < n) {
out += query[i] + query[i + 1];
i += 2;
} else {
out += query[i];
i++;
}
}
if (i < n) {
out += query[i];
i++;
}
continue;
}
if (ch === '`') {
out += ch;
i++;
while (i < n && query[i] !== '`') {
out += query[i];
i++;
}
if (i < n) {
out += query[i];
i++;
}
continue;
}
if (ch === '#') {
while (i < n && query[i] !== '\n') i++;
continue;
}
out += ch;
i++;
}
return out;
}

/**
* Find the index of the `}` that closes the brace opened at `start` in
* `query[start]`. LogQL string literals (double-quoted, with backslash
* escapes, or backtick-delimited raw strings) are skipped as opaque spans so
* that a brace or quote inside a label value can't be mistaken for the real
* boundary. Returns -1 if unterminated.
*/
function findMatchingBrace(query: string, start: number): number {
let depth = 0;
let i = start;
while (i < query.length) {
const ch = query[i];
if (ch === '"') {
i++;
while (i < query.length && query[i] !== '"') {
i += query[i] === '\\' ? 2 : 1;
}
i++;
continue;
}
if (ch === '`') {
i++;
while (i < query.length && query[i] !== '`') i++;
i++;
continue;
}
if (ch === '{') depth++;
else if (ch === '}') {
depth--;
if (depth === 0) return i;
}
i++;
}
return -1;
}

/**
* Extract every LogQL stream selector — each top-level `{...}` brace group —
* from a query. LogQL metric queries can combine more than one selector via
* binary operators (e.g. `sum(rate({a}[5m])) / sum(rate({b}[5m]))`), so a
* namespace lockdown must check all of them, not just the first. Returns null
* if any selector is unterminated.
*/
function extractAllStreamSelectors(query: string): string[] | null {
const selectors: string[] = [];
let i = 0;
while (i < query.length) {
const ch = query[i];
if (ch === '"') {
i++;
while (i < query.length && query[i] !== '"') {
i += query[i] === '\\' ? 2 : 1;
}
i++;
continue;
}
if (ch === '`') {
i++;
while (i < query.length && query[i] !== '`') i++;
i++;
continue;
}
if (ch === '{') {
const end = findMatchingBrace(query, i);
if (end === -1) return null;
selectors.push(query.slice(i, end + 1));
i = end + 1;
continue;
}
i++;
}
return selectors;
}

interface LabelMatcher {
label: string;
op: string;
value: string;
}

const MATCHER_OPS = ['=~', '!~', '!=', '='];

/**
* Parse a `{...}` LogQL stream selector into its label matchers. Returns null
* on anything that doesn't parse as a well-formed matcher list — callers
* should fail closed (reject) rather than guess at a malformed selector.
*/
function parseSelectorMatchers(selector: string): LabelMatcher[] | null {
const inner = selector.slice(1, -1);
const matchers: LabelMatcher[] = [];
let i = 0;
const n = inner.length;
while (i < n) {
while (i < n && /[\s,]/.test(inner[i])) i++;
if (i >= n) break;

const labelStart = i;
while (i < n && /[A-Za-z0-9_]/.test(inner[i])) i++;
const label = inner.slice(labelStart, i);
if (!label) return null;

while (i < n && /\s/.test(inner[i])) i++;
const op = MATCHER_OPS.find((candidate) => inner.startsWith(candidate, i));
if (!op) return null;
i += op.length;
while (i < n && /\s/.test(inner[i])) i++;

const quote = inner[i];
if (quote !== '"' && quote !== '`') return null;
i++;
let value = '';
if (quote === '"') {
while (i < n && inner[i] !== '"') {
if (inner[i] === '\\' && i + 1 < n) {
// Only `\"` and `\\` decode to themselves under this naive scan.
// Any other escape (`\n`, `p`, octal, ...) needs real LogQL
// string-literal decoding to get the right value — since we don't
// implement that, fail closed rather than silently mis-decoding
// (e.g. treating `prod` as literal "u0070rod" instead of the
// "prod" Loki would actually parse it as).
if (inner[i + 1] !== '"' && inner[i + 1] !== '\\') return null;
value += inner[i + 1];
i += 2;
} else {
value += inner[i];
i++;
}
}
} else {
while (i < n && inner[i] !== '`') {
value += inner[i];
i++;
}
}
if (i >= n) return null; // unterminated value
i++; // consume closing quote/backtick

matchers.push({ label, op, value });
}
return matchers;
}

/**
* Check that every LogQL stream selector in a query contains an exact
* namespace matcher for the locked namespace. Accepts namespace="<ns>" or
* namespace=~"<ns>" (treated as an exact-string match, not a real regex
* evaluation), rejecting anything else — a different value, a wildcard
* regex, negated operators (!=, !~), or no namespace matcher at all.
*
* Metric queries can combine multiple selectors via binary operators (e.g.
* `sum(rate({a}[5m])) / sum(rate({b}[5m]))`), so every selector found must
* pass — checking only the first would let a later selector read an
* unlocked namespace while the whole query is forwarded to Loki unchanged.
* A query with no selector at all is rejected (fail closed).
*
* Each selector is fully parsed into label/operator/value matchers rather
* than regex-matched as raw text: a naive substring/regex check over the
* selector text can be spoofed by decoy text inside another matcher's
* backtick-quoted (unescaped) value — e.g. `app=~\`namespace="prod"\`` — while
* the real namespace matcher targets something else entirely. Parsing each
* matcher's value independently closes that off. `#`-comments are stripped
* first so a decoy selector hidden in a comment (which Loki itself ignores)
* can't be validated in place of the real, executed selector.
* namespace matcher for the locked namespace. See
* {@link validateNamespaceSelectorLockdown} in `selector-lockdown.ts` for the
* full semantics (shared with the `prometheus_query` tool, since PromQL and
* LogQL selectors share the same `{label="value"}` grammar).
*/
export function validateNamespaceLockdown(query: string, lockedNamespace: string): boolean {
const selectors = extractAllStreamSelectors(stripLogQLComments(query));
if (selectors === null || selectors.length === 0) return false;
return selectors.every((selector) => {
const matchers = parseSelectorMatchers(selector);
if (matchers === null) return false;
return matchers.some(
(m) => m.label === 'namespace' && (m.op === '=' || m.op === '=~') && m.value === lockedNamespace,
);
});
}
export const validateNamespaceLockdown = validateNamespaceSelectorLockdown;

export interface LokiQueryParams {
query: string;
Expand Down
Loading
Loading