Skip to content

Commit e484224

Browse files
authored
Fix denylist bypass via quoted commands (#472)
* Fix denylist bypass via quoted commands Signed-off-by: ravjotb <ravjot.brar@improving.com> * Fix ReDoS vulnerability in parseCommandArgs regex Signed-off-by: ravjotb <ravjot.brar@improving.com> * Fix lint: use double quotes in test file Signed-off-by: ravjotb <ravjot.brar@improving.com> * Replace regex parser with iterative parser to fix ReDoS Signed-off-by: ravjotb <ravjot.brar@improving.com> --------- Signed-off-by: ravjotb <ravjot.brar@improving.com>
1 parent 900a683 commit e484224

5 files changed

Lines changed: 127 additions & 19 deletions

File tree

apps/frontend/src/components/send-command/SendCommand.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useSelector } from "react-redux"
44
import { useParams } from "react-router"
55
import { toast } from "sonner"
66
import { truncateText } from "@common/src/truncate-text"
7-
import { findBlockedCommand, findConfirmCommand } from "@common/src/command-restrictions"
7+
import { findBlockedCommand, findConfirmCommand, parseCommandArgs } from "@common/src/command-restrictions"
88
import type { JSONObject } from "@common/src/json-utils.ts"
99
import { matchCommands, type MatchResult, type ValkeyCommand } from "@/components/send-command/valkey-command-matching"
1010
import { CommandAutocomplete } from "@/components/send-command/CommandAutocomplete"
@@ -77,14 +77,15 @@ export function SendCommand() {
7777

7878
const onSubmit = (command?: string) => {
7979
const cmd = command || text
80+
const parsedArgs = parseCommandArgs(cmd)
8081

81-
const blocked = findBlockedCommand(cmd)
82+
const blocked = findBlockedCommand(parsedArgs)
8283
if (blocked) {
8384
toast.error(`Command blocked: ${blocked.reason}`)
8485
return
8586
}
8687

87-
const confirm = findConfirmCommand(cmd)
88+
const confirm = findConfirmCommand(parsedArgs)
8889
if (confirm) {
8990
setPendingConfirm({ command: cmd, reason: confirm.reason })
9091
return

apps/server/src/actions/command.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { VALKEY, findBlockedCommand } from "valkey-common"
1+
import { VALKEY, findBlockedCommand, parseCommandArgs } from "valkey-common"
22
import { sendValkeyRunCommand } from "../send-command"
33
import { type Deps, withDeps } from "./utils"
44

@@ -11,7 +11,7 @@ export const sendRequested = withDeps<Deps, void>(
1111
async ({ ws, clients, connectionId, action }) => {
1212
const payload = action.payload as CommandAction
1313

14-
const blocked = findBlockedCommand(payload.command)
14+
const blocked = findBlockedCommand(parseCommandArgs(payload.command))
1515
if (blocked) {
1616
ws.send(
1717
JSON.stringify({

apps/server/src/send-command.ts

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,13 @@
11
import { GlideClient, GlideClusterClient, ConnectionError, ClosingError, TimeoutError } from "@valkey/valkey-glide"
22
import WebSocket from "ws"
3-
import { VALKEY } from "valkey-common"
3+
import { VALKEY, parseCommandArgs } from "valkey-common"
44
import { parseResponse } from "./utils"
55

66
export const isRequestError = (x: unknown): x is Error | string =>
77
x instanceof Error ||
88
(typeof x === "string" && x.startsWith("ResponseError:"))
99

10-
/**
11-
* Parses a command string into arguments, respecting quoted strings and escaped quotes.
12-
* Matches valkey-cli behavior: 'GET "my key"' → ['GET', 'my key']
13-
*/
14-
export const parseCommandArgs = (command: string): string[] =>
15-
command.trim().match(/(?:[^\s"']+|"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')+/g)
16-
?.map((arg) => arg.replace(/^["']|["']$/g, "").replace(/\\(["'])/g, "$1")) ?? []
10+
export { parseCommandArgs }
1711

1812
export async function sendValkeyRunCommand(
1913
client: GlideClient | GlideClusterClient,
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { describe, it } from "node:test"
2+
import assert from "node:assert"
3+
import { findBlockedCommand, findConfirmCommand, parseCommandArgs } from "../command-restrictions"
4+
5+
describe("command restrictions", () => {
6+
describe("findBlockedCommand", () => {
7+
it("blocks FLUSHALL", () => {
8+
assert.ok(findBlockedCommand(parseCommandArgs("FLUSHALL")))
9+
})
10+
11+
it("blocks FLUSHALL with arguments", () => {
12+
assert.ok(findBlockedCommand(parseCommandArgs("FLUSHALL ASYNC")))
13+
})
14+
15+
it("blocks quoted FLUSHALL (bypass fix)", () => {
16+
assert.ok(findBlockedCommand(parseCommandArgs("\"FLUSHALL\"")))
17+
})
18+
19+
it("blocks single-quoted FLUSHALL (bypass fix)", () => {
20+
assert.ok(findBlockedCommand(parseCommandArgs("'FLUSHALL'")))
21+
})
22+
23+
it("blocks SHUTDOWN regardless of quoting", () => {
24+
assert.ok(findBlockedCommand(parseCommandArgs("\"SHUTDOWN\"")))
25+
})
26+
27+
it("blocks DEBUG regardless of quoting", () => {
28+
assert.ok(findBlockedCommand(parseCommandArgs("\"DEBUG\" SLEEP 1")))
29+
})
30+
31+
it("does not block normal commands", () => {
32+
assert.strictEqual(findBlockedCommand(parseCommandArgs("GET mykey")), undefined)
33+
})
34+
35+
it("is case-insensitive", () => {
36+
assert.ok(findBlockedCommand(parseCommandArgs("flushall")))
37+
assert.ok(findBlockedCommand(parseCommandArgs("\"flushdb\"")))
38+
})
39+
})
40+
41+
describe("findConfirmCommand", () => {
42+
it("requires confirmation for KEYS", () => {
43+
assert.ok(findConfirmCommand(parseCommandArgs("KEYS *")))
44+
})
45+
46+
it("requires confirmation for quoted KEYS (bypass fix)", () => {
47+
assert.ok(findConfirmCommand(parseCommandArgs("\"KEYS\" *")))
48+
})
49+
50+
it("requires confirmation for CLUSTER RESET", () => {
51+
assert.ok(findConfirmCommand(parseCommandArgs("CLUSTER RESET")))
52+
})
53+
54+
it("requires confirmation for quoted CLUSTER RESET", () => {
55+
assert.ok(findConfirmCommand(parseCommandArgs("\"CLUSTER\" \"RESET\"")))
56+
})
57+
58+
it("does not confirm normal commands", () => {
59+
assert.strictEqual(findConfirmCommand(parseCommandArgs("GET mykey")), undefined)
60+
})
61+
})
62+
})

common/src/command-restrictions.ts

Lines changed: 57 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,57 @@ export type CommandRestriction = {
33
reason: string
44
}
55

6+
/**
7+
* Parses a command string into arguments, respecting quoted strings and escaped quotes.
8+
* Matches valkey-cli behavior: 'GET "my key"' → ['GET', 'my key']
9+
*/
10+
export const parseCommandArgs = (command: string): string[] => {
11+
const args: string[] = []
12+
const input = command.trim()
13+
let i = 0
14+
15+
while (i < input.length) {
16+
// Skip whitespace
17+
while (i < input.length && /\s/.test(input[i])) i++
18+
if (i >= input.length) break
19+
20+
let arg = ""
21+
if (input[i] === "\"") {
22+
// Double-quoted string
23+
i++ // skip opening quote
24+
while (i < input.length && input[i] !== "\"") {
25+
if (input[i] === "\\" && i + 1 < input.length) {
26+
i++ // skip backslash
27+
}
28+
arg += input[i]
29+
i++
30+
}
31+
i++ // skip closing quote
32+
} else if (input[i] === "'") {
33+
// Single-quoted string
34+
i++ // skip opening quote
35+
while (i < input.length && input[i] !== "'") {
36+
if (input[i] === "\\" && i + 1 < input.length) {
37+
i++ // skip backslash
38+
}
39+
arg += input[i]
40+
i++
41+
}
42+
i++ // skip closing quote
43+
} else {
44+
// Unquoted token
45+
while (i < input.length && !/[\s"']/.test(input[i])) {
46+
arg += input[i]
47+
i++
48+
}
49+
}
50+
51+
args.push(arg)
52+
}
53+
54+
return args
55+
}
56+
657
// these commands are blocked and cannot be executed because they can cause server problems
758
export const BLOCKED_COMMANDS: CommandRestriction[] = [
859
{ pattern: ["SHUTDOWN"], reason: "SHUTDOWN stops the server and cannot be undone remotely." },
@@ -21,18 +72,18 @@ export const CONFIRM_COMMANDS: CommandRestriction[] = [
2172
{ pattern: ["CLUSTER", "RESET"], reason: "CLUSTER RESET resets the cluster state and may cause data loss." },
2273
]
2374

24-
export function matchesRestriction(command: string, restriction: CommandRestriction): boolean {
25-
const parts = command.trim().toUpperCase().split(/\s+/)
75+
export function matchesRestriction(parsedArgs: string[], restriction: CommandRestriction): boolean {
76+
const parts = parsedArgs.map((p) => p.toUpperCase())
2677
return (
2778
restriction.pattern.length <= parts.length &&
2879
restriction.pattern.every((token, i) => parts[i] === token)
2980
)
3081
}
3182

32-
export function findBlockedCommand(command: string): CommandRestriction | undefined {
33-
return BLOCKED_COMMANDS.find((r) => matchesRestriction(command, r))
83+
export function findBlockedCommand(parsedArgs: string[]): CommandRestriction | undefined {
84+
return BLOCKED_COMMANDS.find((r) => matchesRestriction(parsedArgs, r))
3485
}
3586

36-
export function findConfirmCommand(command: string): CommandRestriction | undefined {
37-
return CONFIRM_COMMANDS.find((r) => matchesRestriction(command, r))
87+
export function findConfirmCommand(parsedArgs: string[]): CommandRestriction | undefined {
88+
return CONFIRM_COMMANDS.find((r) => matchesRestriction(parsedArgs, r))
3889
}

0 commit comments

Comments
 (0)