Repository navigation
Description
Description
A script defined with IS_READ_ONLY: true is sent as EVALSHA_RO, but when the server answers NOSCRIPT the client retries with plain EVAL, and multi() always queues EVAL/EVALSHA for it. So a user whose ACL allows only EVAL_RO and EVALSHA_RO gets NOPERM on every call. Since the EVAL never runs, the script is never cached either, and it keeps failing until someone runs SCRIPT LOAD by hand.
Expected: a read-only script falls back to EVAL_RO, and MULTI uses EVALSHA_RO/EVAL_RO, so that user can run it.
// REDIS_URL=redis://localhost:6379 node repro.mjs
import { createClient, defineScript } from "redis";
const url = process.env.REDIS_URL;
const ro = defineScript({
SCRIPT: "return redis.call('GET', KEYS[1])",
NUMBER_OF_KEYS: 1,
IS_READ_ONLY: true,
parseCommand(parser, key) { parser.pushKey(key); },
transformReply: (reply) => reply
});
const admin = await createClient({ url }).connect();
await admin.sendCommand(["ACL", "SETUSER", "ro-user", "reset", "on", ">pw", "~*",
"+hello", "+auth", "+ping", "+client|setinfo", "+eval_ro", "+evalsha_ro", "+get", "+multi", "+exec"]);
await admin.set("k", "v");
await admin.scriptFlush();
const client = await createClient({ url, username: "ro-user", password: "pw", scripts: { ro } })
.on("error", () => {})
.connect();
for (let i = 0; i < 3; i++) {
console.log("call:", await client.ro("k").catch((err) => err.message));
}
for (let i = 0; i < 3; i++) {
console.log("multi:", await client.multi().ro("k").exec().catch((err) => err.message));
}
client.destroy();
await admin.sendCommand(["ACL", "DELUSER", "ro-user"]);
admin.destroy();The output is under Logs, the same on 3 of 3 runs. The first attempt picks the command from IS_READ_ONLY:
node-redis/packages/client/lib/commander.ts
Line 133 in 55934ec
| script.IS_READ_ONLY ? 'EVALSHA_RO' : 'EVALSHA', |
but the NOSCRIPT retry in _executeScript does not:
node-redis/packages/client/lib/client/index.ts
Line 1728 in 55934ec
| args[0] = 'EVAL'; |
and neither does addScript in MULTI:
node-redis/packages/client/lib/multi-command.ts
Lines 43 to 51 in 55934ec
| addScript(script: RedisScript, args: CommandArguments, transformReply?: TransformReply) { | |
| const redisArgs: CommandArguments = []; | |
| redisArgs.preserve = args.preserve; | |
| if (this.scriptsInUse.has(script.SHA1)) { | |
| redisArgs.push('EVALSHA', script.SHA1); | |
| } else { | |
| this.scriptsInUse.add(script.SHA1); | |
| redisArgs.push('EVAL', script.SCRIPT); | |
| } |
Proposed fix: use EVAL_RO in the retry and EVALSHA_RO/EVAL_RO in addScript when IS_READ_ONLY is set, the same choice scriptArgumentsPrefix makes. One behaviour change comes with it: a script marked read-only that writes currently succeeds on its first call and inside MULTI (it goes out as EVAL), and fails from the second call on. With the fix the server rejects it every time. I'd like to know if that change is fine with you before opening the PR. The fix touches client/index.ts and multi-command.ts; I can open a PR with failing tests for both paths.
Node.js Version
v22.23.3
Redis Server Version
8.10.2
Node Redis Version
redis@6.3.1 (the lines linked above are the same on master 55934ec)
Platform
Linux
Logs
call: NOPERM User ro-user has no permissions to run the 'eval' command
call: NOPERM User ro-user has no permissions to run the 'eval' command
call: NOPERM User ro-user has no permissions to run the 'eval' command
multi: NOPERM User ro-user has no permissions to run the 'eval' command
multi: NOPERM User ro-user has no permissions to run the 'eval' command
multi: NOPERM User ro-user has no permissions to run the 'eval' command
Activity
nkaradzhov commentedon Oct 31, 2026
Thanks for the report, @maxymlyskov. The behaviour change is fine. Please go ahead and open the PR.