Skip to content

Commit 58476a8

Browse files
authored
Merge pull request #407 from ably/fix/web-cli-push-payload-file-read
[VUL-506] fix(push): stop web CLI from reading server-side files via payload args
2 parents b28b091 + 93dd668 commit 58476a8

8 files changed

Lines changed: 335 additions & 60 deletions

File tree

.claude/skills/ably-new-command/SKILL.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,7 +429,10 @@ If the new command shouldn't be available in the web CLI, add it to the appropri
429429
- `WEB_CLI_ANONYMOUS_RESTRICTED_COMMANDS`for commands that expose account/app data
430430
- `INTERACTIVE_UNSUITABLE_COMMANDS`for commands that don't work in interactive mode
431431

432-
**Security rule:** Any command that reads or uploads files from the filesystem (e.g., certificate uploads, key file imports) **must** be added to `WEB_CLI_RESTRICTED_COMMANDS`. The web CLI runs on a serverfile-reading commands would read from the server's filesystem, not the user's local machine, which could expose server contents. There is no file upload mechanism in the web CLI to transfer local files.
432+
**Security rule:** The web CLI runs on a shared serverany command that reads files from the filesystem reads the *server's* files, not the user's local machine, which can expose server contents. There is no file-upload mechanism in the web CLI to transfer local files. So for any command that reads files, choose one of:
433+
434+
1. **Block the command** — add it to `WEB_CLI_RESTRICTED_COMMANDS` when the *entire* command is meaningless without local files (e.g. `push config set-fcm`/`set-apns` exist only to upload a credentials file).
435+
2. **Disable just the file read** — when the command is still useful with inline input (e.g. `push publish`/`batch-publish`/`devices save` accept JSON inline), load the input through the `this.resolveJsonInput(input, label, flags, component)` base-command helper. It reads `@file`/path inputs from disk in local mode but, in web CLI mode, treats the input as literal data and rejects `@file` referencesso the command keeps working without a server-side file-read sink. **Never** call `fs.readFileSync` directly on flag/arg values; always go through `resolveJsonInput` so the web-CLI restriction is inherited rather than re-implemented (and re-missed) per command.
433436

434437
## Step 6: Validate
435438

src/base-command.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import {
3838
} from "./utils/long-running.js";
3939
import isTestMode from "./utils/test-mode.js";
4040
import isWebCliMode from "./utils/web-mode.js";
41+
import * as fs from "node:fs";
42+
import * as path from "node:path";
4143

4244
// List of commands not allowed in web CLI mode - EXPORTED
4345
export const WEB_CLI_RESTRICTED_COMMANDS = [
@@ -1709,6 +1711,75 @@ export abstract class AblyBaseCommand extends InteractiveBaseCommand {
17091711
}
17101712
}
17111713

1714+
/**
1715+
* Resolve a JSON input that may be either a literal JSON string or a
1716+
* reference to a local file. Supports the documented `@path` prefix, bare
1717+
* path prefixes (`/`, `./`, `../`), and bare paths that happen to exist on
1718+
* disk. Returns the raw JSON string for the caller to parse.
1719+
*
1720+
* SECURITY: in web CLI mode the filesystem belongs to the shared
1721+
* terminal-server container, not the user's machine — reading from it
1722+
* would disclose server-side files. So in web CLI mode this NEVER touches the
1723+
* filesystem: the input is always treated as a literal JSON string, and any
1724+
* file reference (`@file` or a path prefix) is rejected with a clear error.
1725+
* Any command that accepts a JSON-or-file input MUST go through this helper so
1726+
* the restriction is inherited rather than re-implemented per command.
1727+
*/
1728+
protected resolveJsonInput(
1729+
input: string,
1730+
inputLabel: string,
1731+
flags: BaseFlags,
1732+
component: string,
1733+
): string {
1734+
// Web CLI: the filesystem is server-side. Never read it; treat the input as
1735+
// literal data. Reject anything that looks like a file reference explicitly
1736+
// so users get a clear message instead of a confusing JSON parse error.
1737+
// Detection is prefix-based only — we must not probe `existsSync` here, as
1738+
// that would itself touch the server's filesystem.
1739+
if (this.isWebCliMode) {
1740+
const looksLikeFileReference =
1741+
input.startsWith("@") ||
1742+
input.startsWith("/") ||
1743+
input.startsWith("./") ||
1744+
input.startsWith("../");
1745+
if (looksLikeFileReference) {
1746+
this.fail(
1747+
`Loading ${inputLabel} from a file is not supported in the web CLI. Pass the JSON inline instead.`,
1748+
flags,
1749+
component,
1750+
);
1751+
}
1752+
return input;
1753+
}
1754+
1755+
// Local CLI: load from a file when the input is a file reference.
1756+
if (input.startsWith("@")) {
1757+
const filePath = path.resolve(input.slice(1));
1758+
if (!fs.existsSync(filePath)) {
1759+
this.fail(`File not found: ${filePath}`, flags, component);
1760+
}
1761+
return fs.readFileSync(filePath, "utf8");
1762+
}
1763+
1764+
if (
1765+
input.startsWith("/") ||
1766+
input.startsWith("./") ||
1767+
input.startsWith("../")
1768+
) {
1769+
const filePath = path.resolve(input);
1770+
if (!fs.existsSync(filePath)) {
1771+
this.fail(`File not found: ${filePath}`, flags, component);
1772+
}
1773+
return fs.readFileSync(filePath, "utf8");
1774+
}
1775+
1776+
if (fs.existsSync(path.resolve(input))) {
1777+
return fs.readFileSync(path.resolve(input), "utf8");
1778+
}
1779+
1780+
return input;
1781+
}
1782+
17121783
/**
17131784
* Parse a flag value as a JSON object. Rejects arrays and primitives.
17141785
*/

src/commands/push/batch-publish.ts

Lines changed: 6 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { Args } from "@oclif/core";
2-
import * as fs from "node:fs";
3-
import * as path from "node:path";
42

53
import { AblyBaseCommand } from "../../base-command.js";
64
import { CommandError } from "../../errors/command-error.js";
@@ -108,26 +106,13 @@ export default class PushBatchPublish extends AblyBaseCommand {
108106
jsonString = await this.readStdin();
109107
} else if (payloadArg === "-") {
110108
jsonString = await this.readStdin();
111-
} else if (payloadArg.startsWith("@")) {
112-
const filePath = path.resolve(payloadArg.slice(1));
113-
if (!fs.existsSync(filePath)) {
114-
this.fail(`File not found: ${filePath}`, flags, "pushBatchPublish");
115-
}
116-
jsonString = fs.readFileSync(filePath, "utf8");
117-
} else if (
118-
payloadArg.startsWith("/") ||
119-
payloadArg.startsWith("./") ||
120-
payloadArg.startsWith("../")
121-
) {
122-
const filePath = path.resolve(payloadArg);
123-
if (!fs.existsSync(filePath)) {
124-
this.fail(`File not found: ${filePath}`, flags, "pushBatchPublish");
125-
}
126-
jsonString = fs.readFileSync(filePath, "utf8");
127-
} else if (fs.existsSync(path.resolve(payloadArg))) {
128-
jsonString = fs.readFileSync(path.resolve(payloadArg), "utf8");
129109
} else {
130-
jsonString = payloadArg;
110+
jsonString = this.resolveJsonInput(
111+
payloadArg,
112+
"the batch payload",
113+
flags,
114+
"pushBatchPublish",
115+
);
131116
}
132117

133118
let batchPayload: unknown[];

src/commands/push/devices/save.ts

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { Flags } from "@oclif/core";
2-
import * as fs from "node:fs";
3-
import * as path from "node:path";
42

53
import { AblyBaseCommand } from "../../../base-command.js";
64
import { productApiFlags } from "../../../flags.js";
@@ -202,15 +200,12 @@ export default class PushDevicesSave extends AblyBaseCommand {
202200
data: string,
203201
flags: Record<string, unknown>,
204202
): Record<string, unknown> {
205-
let jsonString = data;
206-
207-
if (data.startsWith("@")) {
208-
const filePath = path.resolve(data.slice(1));
209-
if (!fs.existsSync(filePath)) {
210-
this.fail(`File not found: ${filePath}`, flags, "pushDeviceSave");
211-
}
212-
jsonString = fs.readFileSync(filePath, "utf8");
213-
}
203+
const jsonString = this.resolveJsonInput(
204+
data,
205+
"--data",
206+
flags,
207+
"pushDeviceSave",
208+
);
214209

215210
let parsed: unknown;
216211
try {

src/commands/push/publish.ts

Lines changed: 6 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
import { Flags } from "@oclif/core";
2-
import * as fs from "node:fs";
3-
import * as path from "node:path";
42

53
import { AblyBaseCommand } from "../../base-command.js";
64
import { forceFlag, productApiFlags } from "../../flags.js";
@@ -148,28 +146,12 @@ export default class PushPublish extends AblyBaseCommand {
148146
// Build notification payload
149147
let payload: Record<string, unknown>;
150148
if (flags.payload) {
151-
let jsonString: string;
152-
if (flags.payload.startsWith("@")) {
153-
const filePath = path.resolve(flags.payload.slice(1));
154-
if (!fs.existsSync(filePath)) {
155-
this.fail(`File not found: ${filePath}`, flags, "pushPublish");
156-
}
157-
jsonString = fs.readFileSync(filePath, "utf8");
158-
} else if (
159-
flags.payload.startsWith("/") ||
160-
flags.payload.startsWith("./") ||
161-
flags.payload.startsWith("../")
162-
) {
163-
const filePath = path.resolve(flags.payload);
164-
if (!fs.existsSync(filePath)) {
165-
this.fail(`File not found: ${filePath}`, flags, "pushPublish");
166-
}
167-
jsonString = fs.readFileSync(filePath, "utf8");
168-
} else if (fs.existsSync(path.resolve(flags.payload))) {
169-
jsonString = fs.readFileSync(path.resolve(flags.payload), "utf8");
170-
} else {
171-
jsonString = flags.payload;
172-
}
149+
const jsonString = this.resolveJsonInput(
150+
flags.payload,
151+
"--payload",
152+
flags,
153+
"pushPublish",
154+
);
173155
payload = this.parseJsonObjectFlag(jsonString, "--payload", flags);
174156
} else {
175157
const notification: Record<string, unknown> = {};

test/unit/commands/push/batch-publish.test.ts

Lines changed: 76 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { describe, it, expect, beforeEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { runCommand } from "@oclif/test";
3+
import * as fs from "node:fs";
4+
import * as os from "node:os";
5+
import * as path from "node:path";
36
import { getMockAblyRest } from "../../../helpers/mock-ably-rest.js";
47
import {
58
standardHelpTests,
@@ -483,4 +486,76 @@ describe("push:batch-publish command", () => {
483486
expect(error?.message).toContain("40300");
484487
});
485488
});
489+
490+
// In web CLI mode the batch payload must never be read from the server's
491+
// filesystem. The file-loading shortcut is local-CLI only.
492+
describe("web CLI file-read restriction", () => {
493+
let originalWebCliMode: string | undefined;
494+
let secretFile: string;
495+
496+
beforeEach(() => {
497+
originalWebCliMode = process.env.ABLY_WEB_CLI_MODE;
498+
secretFile = path.join(os.tmpdir(), `vul506-batch-${process.pid}.json`);
499+
fs.writeFileSync(
500+
secretFile,
501+
'[{"recipient":{"deviceId":"dev-1"},"payload":{"notification":{"title":"SECRET_FROM_FILE"}}}]',
502+
);
503+
});
504+
505+
afterEach(() => {
506+
if (originalWebCliMode === undefined) {
507+
delete process.env.ABLY_WEB_CLI_MODE;
508+
} else {
509+
process.env.ABLY_WEB_CLI_MODE = originalWebCliMode;
510+
}
511+
if (fs.existsSync(secretFile)) fs.rmSync(secretFile);
512+
});
513+
514+
it("reads a local file payload when NOT in web CLI mode", async () => {
515+
const mock = getMockAblyRest();
516+
mock.request.mockResolvedValue({ statusCode: 200, items: [] });
517+
518+
const { stderr } = await runCommand(
519+
["push:batch-publish", secretFile, "--force"],
520+
import.meta.url,
521+
);
522+
523+
expect(stderr).toContain("published");
524+
expect(mock.request).toHaveBeenCalledWith(
525+
"post",
526+
"/push/batch/publish",
527+
2,
528+
null,
529+
expect.any(Array),
530+
);
531+
});
532+
533+
it("rejects a server-side file path in web CLI mode without reading it", async () => {
534+
process.env.ABLY_WEB_CLI_MODE = "true";
535+
const mock = getMockAblyRest();
536+
537+
const { error } = await runCommand(
538+
["push:batch-publish", secretFile, "--force"],
539+
import.meta.url,
540+
);
541+
542+
// A path-like payload is rejected with a clear message and the file
543+
// contents are never read or published.
544+
expect(error).toBeDefined();
545+
expect(error?.message).toContain("not supported in the web CLI");
546+
expect(mock.request).not.toHaveBeenCalled();
547+
});
548+
549+
it("rejects @file payload references in web CLI mode", async () => {
550+
process.env.ABLY_WEB_CLI_MODE = "true";
551+
552+
const { error } = await runCommand(
553+
["push:batch-publish", `@${secretFile}`, "--force"],
554+
import.meta.url,
555+
);
556+
557+
expect(error).toBeDefined();
558+
expect(error?.message).toContain("not supported in the web CLI");
559+
});
560+
});
486561
});

test/unit/commands/push/devices/save.test.ts

Lines changed: 91 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
1-
import { describe, it, expect, beforeEach } from "vitest";
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest";
22
import { runCommand } from "@oclif/test";
3+
import * as fs from "node:fs";
4+
import * as os from "node:os";
5+
import * as path from "node:path";
36
import { getMockAblyRest } from "../../../../helpers/mock-ably-rest.js";
47
import {
58
standardHelpTests,
@@ -307,4 +310,91 @@ describe("push:devices:save command", () => {
307310
expect(error?.message).toContain("must be a JSON object");
308311
});
309312
});
313+
314+
// In web CLI mode --data must never be read from the server's filesystem.
315+
// The @file shortcut is local-CLI only.
316+
describe("web CLI file-read restriction", () => {
317+
let originalWebCliMode: string | undefined;
318+
let secretFile: string;
319+
320+
beforeEach(() => {
321+
originalWebCliMode = process.env.ABLY_WEB_CLI_MODE;
322+
secretFile = path.join(os.tmpdir(), `vul506-device-${process.pid}.json`);
323+
fs.writeFileSync(
324+
secretFile,
325+
'{"id":"device-2","platform":"android","formFactor":"tablet","push":{"recipient":{"transportType":"fcm","registrationToken":"tok"}}}',
326+
);
327+
});
328+
329+
afterEach(() => {
330+
if (originalWebCliMode === undefined) {
331+
delete process.env.ABLY_WEB_CLI_MODE;
332+
} else {
333+
process.env.ABLY_WEB_CLI_MODE = originalWebCliMode;
334+
}
335+
if (fs.existsSync(secretFile)) fs.rmSync(secretFile);
336+
});
337+
338+
it("reads a local --data @file when NOT in web CLI mode", async () => {
339+
const mock = getMockAblyRest();
340+
mock.push.admin.deviceRegistrations.save.mockResolvedValue({
341+
id: "device-2",
342+
});
343+
344+
const { stderr } = await runCommand(
345+
["push:devices:save", "--data", `@${secretFile}`],
346+
import.meta.url,
347+
);
348+
349+
expect(stderr).toContain("Device registration saved");
350+
expect(mock.push.admin.deviceRegistrations.save).toHaveBeenCalledWith(
351+
expect.objectContaining({ id: "device-2", platform: "android" }),
352+
);
353+
});
354+
355+
it("reads a local --data path input when NOT in web CLI mode", async () => {
356+
const mock = getMockAblyRest();
357+
mock.push.admin.deviceRegistrations.save.mockResolvedValue({
358+
id: "device-2",
359+
});
360+
361+
const { stderr } = await runCommand(
362+
["push:devices:save", "--data", secretFile],
363+
import.meta.url,
364+
);
365+
366+
expect(stderr).toContain("Device registration saved");
367+
expect(mock.push.admin.deviceRegistrations.save).toHaveBeenCalledWith(
368+
expect.objectContaining({ id: "device-2", platform: "android" }),
369+
);
370+
});
371+
372+
it("rejects --data @file references in web CLI mode", async () => {
373+
process.env.ABLY_WEB_CLI_MODE = "true";
374+
const mock = getMockAblyRest();
375+
376+
const { error } = await runCommand(
377+
["push:devices:save", "--data", `@${secretFile}`],
378+
import.meta.url,
379+
);
380+
381+
expect(error).toBeDefined();
382+
expect(error?.message).toContain("not supported in the web CLI");
383+
expect(mock.push.admin.deviceRegistrations.save).not.toHaveBeenCalled();
384+
});
385+
386+
it("rejects a --data path input in web CLI mode without reading it", async () => {
387+
process.env.ABLY_WEB_CLI_MODE = "true";
388+
const mock = getMockAblyRest();
389+
390+
const { error } = await runCommand(
391+
["push:devices:save", "--data", secretFile],
392+
import.meta.url,
393+
);
394+
395+
expect(error).toBeDefined();
396+
expect(error?.message).toContain("not supported in the web CLI");
397+
expect(mock.push.admin.deviceRegistrations.save).not.toHaveBeenCalled();
398+
});
399+
});
310400
});

0 commit comments

Comments
 (0)