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
82 changes: 82 additions & 0 deletions src/lib/__tests__/loki.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,88 @@ describe('validateNamespaceLockdown', () => {
expect(validateNamespaceLockdown('{namespace="my.ns"} |= "error"', 'my.ns')).toBe(true);
expect(validateNamespaceLockdown('{namespace="myzns"} |= "error"', 'my.ns')).toBe(false);
});

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

it('rejects a mismatched selector even when the locked namespace text appears outside the selector', () => {
expect(
validateNamespaceLockdown('{app="api"} |= "namespace=\\"prod\\""', 'prod'),
).toBe(false);
});

it('rejects a query with no selector at all', () => {
expect(validateNamespaceLockdown('namespace="prod"', 'prod')).toBe(false);
});

it('ignores braces inside quoted regex values when locating the selector boundary', () => {
expect(
validateNamespaceLockdown('{namespace=~"prod-[0-9]{3}"} |= "error"', 'prod-[0-9]{3}'),
).toBe(true);
});

it('rejects a bypass where a backtick-quoted matcher value spoofs the locked namespace text', () => {
// The real selector targets "evil"; the decoy `namespace="prod"` text lives inside a
// backtick raw-string label value (app=~`...`) and inside a backtick line filter.
const query = '{namespace="evil", app=~`foo"bar`} |= `" namespace="prod" }`';
expect(validateNamespaceLockdown(query, 'prod')).toBe(false);
expect(validateNamespaceLockdown(query, 'evil')).toBe(true);
});

it('does not block a legitimate query whose backtick-quoted value contains a brace', () => {
const query = '{namespace="prod", app=~`foo\\{.*`}';
expect(validateNamespaceLockdown(query, 'prod')).toBe(true);
});

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

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);
});

it('rejects a malformed selector rather than guessing', () => {
expect(validateNamespaceLockdown('{namespace=prod} |= "error"', 'prod')).toBe(false);
});

it('rejects a bypass where a decoy selector is hidden in a leading comment', () => {
// Loki ignores the commented line and executes the real (different) selector.
const query = '# {namespace="prod"}\n{namespace="evil"} |= "ERROR"';
expect(validateNamespaceLockdown(query, 'prod')).toBe(false);
expect(validateNamespaceLockdown(query, 'evil')).toBe(true);
});

it('does not treat a "#" inside a quoted value as a comment', () => {
expect(validateNamespaceLockdown('{namespace="pro#d"} |= "error"', 'pro#d')).toBe(true);
});

it('rejects a metric query where only the first of multiple stream selectors matches the locked namespace', () => {
const query = 'sum(rate({namespace="prod"}[5m])) / sum(rate({namespace="evil"}[5m]))';
expect(validateNamespaceLockdown(query, 'prod')).toBe(false);
});

it('accepts a metric query where every stream selector matches the locked namespace', () => {
const query = 'sum(rate({namespace="prod"}[5m])) / sum(rate({namespace="prod", app="api"}[5m]))';
expect(validateNamespaceLockdown(query, 'prod')).toBe(true);
});

it('rejects a selector with a non-simple escape sequence rather than mis-decoding it', () => {
// Loki decodes `p` as "p" (a real LogQL string-literal unicode escape), but a naive
// per-character scan would read it as the literal text "u0070" — fail closed instead of
// guessing wrong in either direction.
const query = '{namespace="\\u0070rod"} |= "error"';
expect(validateNamespaceLockdown(query, 'prod')).toBe(false);
expect(validateNamespaceLockdown(query, 'u0070rod')).toBe(false);
});

it('still decodes simple `\\"` and `\\\\` escapes in matcher values', () => {
expect(validateNamespaceLockdown('{namespace="prod\\\\"} |= "error"', 'prod\\')).toBe(true);
});
});

// ---------------------------------------------------------------------------
Expand Down
231 changes: 223 additions & 8 deletions src/lib/loki.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import { makeTruncate } from './output-truncation.ts';
import { resolveTimePassthrough } from './time-resolution.ts';
import { runJsonQuery } from './http.ts';
import { clampLimit } from './tool-config.ts';
import { escapeRegExpLiteral } from './regexp-utils.ts';
import { BLOCKED_PREFIX } from './harness.ts';

export interface LokiConfig {
Expand All @@ -33,15 +32,231 @@ const truncate = makeTruncate(MAX_RESULT_CHARS, 'use a narrower time range, smal
export const resolveTime = resolveTimePassthrough;

/**
* Check that a LogQL query contains an exact namespace selector matching the
* locked namespace. Accepts namespace="<ns>" or namespace=~"<ns>" (exact-string
* regex), rejecting selectors that could match other namespaces.
* 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;
Comment thread
billzhuang marked this conversation as resolved.
} 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.
*/
export function validateNamespaceLockdown(query: string, lockedNamespace: string): boolean {
const escaped = escapeRegExpLiteral(lockedNamespace);
const exact = new RegExp(`namespace\\s*=\\s*"${escaped}"`);
const regexExact = new RegExp(`namespace\\s*=~\\s*"${escaped}"`);
return exact.test(query) || regexExact.test(query);
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 interface LokiQueryParams {
Expand Down
Loading