Skip to content

A read-only script falls back to EVAL after NOSCRIPT, so a user allowed only EVAL_RO gets NOPERM on every call #3563

Closed

Description

@maxymlyskov

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:

script.IS_READ_ONLY ? 'EVALSHA_RO' : 'EVALSHA',

but the NOSCRIPT retry in _executeScript does not:

and neither does addScript in MULTI:

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

  1. nkaradzhov commented on Oct 31, 2026

    @nkaradzhov
    Collaborator

    Thanks for the report, @maxymlyskov. The behaviour change is fine. Please go ahead and open the PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions