Skip to content

Commit 59b0eb2

Browse files
Fix(chore): security hardening (#55)
* fix(toast): ignore labelHtml and execJs from untrusted DOM events * fix(mix): contain corex.code output paths within project root * fix(mix): validate generator flags and migration_dir * fix(mix): stop EEx re-eval in inject_eex_before_final_end * fix(installer): harden --dev path and COREX_NEW_CACHE_DIR copy * fix(installer): validate web_module like root module * fix(color-picker): reject CSS breakout in inline style values * fix(components): harden file upload cancel, navigate, and pagination URLs * chore: re-enable sobelow checks and add hex.audit to release.check * fix(mcp): redact tool errors by default and warn on remote access
1 parent 801c2f2 commit 59b0eb2

44 files changed

Lines changed: 862 additions & 117 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.sobelow-conf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
[exit: false, format: "txt", ignore_files: [], ignore: ["Traversal.FileModule", "RCE.EEx", "Config"], out: nil, private: false, router: nil, skip: false, threshold: :low, verbose: false, version: false]
1+
[exit: false, format: "txt", ignore_files: ["lib/mix/corex.ex", "lib/mix/corex/gen/context.ex", "lib/corex/doc_parity.ex"], ignore: ["Config"], out: nil, private: false, router: nil, skip: false, threshold: :low, verbose: false, version: false]

assets/hooks/toast.ts

Lines changed: 19 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ export function parseSingleExecJsEffect(raw: unknown): string | null {
3636
return encoded;
3737
}
3838

39-
export function parseActionSpec(raw: unknown): ToastActionSpec | null {
39+
export function parseServerActionSpec(raw: unknown): ToastActionSpec | null {
4040
const o = asRecord(raw);
4141
const label = o.label;
4242
if (typeof label !== "string" || label.length === 0) return null;
@@ -55,6 +55,12 @@ export function parseActionSpec(raw: unknown): ToastActionSpec | null {
5555
return spec;
5656
}
5757

58+
export function parseDomActionSpec(_raw: unknown): ToastActionSpec | null {
59+
return null;
60+
}
61+
62+
export const parseActionSpec = parseServerActionSpec;
63+
5864
function buildZagAction(
5965
spec: ToastActionSpec,
6066
rt: ToastHookRuntime
@@ -217,8 +223,10 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
217223

218224
const rt = buildRuntime(this);
219225

220-
const buildCreateOptions = (payload: ToastCreatePayload): Options => {
221-
const spec = parseActionSpec(payload.action);
226+
const buildCreateOptions = (payload: ToastCreatePayload, trusted: boolean): Options => {
227+
const spec = trusted
228+
? parseServerActionSpec(payload.action)
229+
: parseDomActionSpec(payload.action);
222230
const base: Options = {
223231
title: payload.title ?? "",
224232
description: payload.description,
@@ -235,7 +243,7 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
235243
return base;
236244
};
237245

238-
const buildUpdatePatch = (payload: ToastUpdatePayload): Partial<Options> => {
246+
const buildUpdatePatch = (payload: ToastUpdatePayload, trusted: boolean): Partial<Options> => {
239247
const patch: Partial<Options> = {};
240248
if (payload.title !== undefined) patch.title = payload.title;
241249
if (payload.description !== undefined) patch.description = payload.description;
@@ -246,7 +254,9 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
246254
} else if (payload.loading === false || payload.loading === "false") {
247255
patch.meta = { loading: false };
248256
}
249-
const spec = parseActionSpec(payload.action);
257+
const spec = trusted
258+
? parseServerActionSpec(payload.action)
259+
: parseDomActionSpec(payload.action);
250260
if (spec) {
251261
patch.action = buildZagAction(spec, rt);
252262
} else if (payload.action === null) {
@@ -284,7 +294,7 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
284294
const st = getToastStore(payload.groupId || this.groupId);
285295
if (!st) return;
286296
try {
287-
st.create(buildCreateOptions(payload));
297+
st.create(buildCreateOptions(payload, true));
288298
} catch (error) {
289299
console.error("Failed to create toast:", error);
290300
}
@@ -296,7 +306,7 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
296306
const st = getToastStore(payload.groupId || this.groupId);
297307
if (!st || !payload.id) return;
298308
try {
299-
st.update(payload.id, buildUpdatePatch(payload));
309+
st.update(payload.id, buildUpdatePatch(payload, true));
300310
} catch (error) {
301311
console.error("Failed to update toast:", error);
302312
}
@@ -312,7 +322,7 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
312322
const st = getToastStore(detail.groupId || this.groupId);
313323
if (!st) return;
314324
try {
315-
st.create(buildCreateOptions(detail));
325+
st.create(buildCreateOptions(detail, false));
316326
} catch (error) {
317327
console.error("Failed to create toast:", error);
318328
}
@@ -323,7 +333,7 @@ const ToastHook: Hook<object & ToastHookState, HTMLElement> = {
323333
const st = getToastStore(detail.groupId || this.groupId);
324334
if (!st || !detail.id) return;
325335
try {
326-
st.update(detail.id, buildUpdatePatch(detail));
336+
st.update(detail.id, buildUpdatePatch(detail, false));
327337
} catch (error) {
328338
console.error("Failed to update toast:", error);
329339
}

assets/test/hooks/toast.test.ts

Lines changed: 85 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,18 @@
11
import { describe, expect, it, afterEach } from "vitest";
22
import type { CallbackRef } from "phoenix_live_view/assets/js/types/view_hook";
3-
import { parseActionSpec, Toast } from "../../hooks/toast";
3+
import {
4+
parseActionSpec,
5+
parseDomActionSpec,
6+
parseServerActionSpec,
7+
Toast,
8+
} from "../../hooks/toast";
49
import { getToastStore } from "../../components/toast";
510
import { callHookDestroyed, callHookMounted, mockHookContext } from "../helpers/mock-hook";
611

7-
describe("parseActionSpec", () => {
12+
describe("parseServerActionSpec", () => {
813
it("parses valid exec_js action", () => {
914
expect(
10-
parseActionSpec({
15+
parseServerActionSpec({
1116
label: "Undo",
1217
effects: [{ kind: "exec_js", encoded: "abc" }],
1318
})
@@ -16,7 +21,7 @@ describe("parseActionSpec", () => {
1621

1722
it("includes className when present", () => {
1823
expect(
19-
parseActionSpec({
24+
parseServerActionSpec({
2025
label: "Go",
2126
class: " button--sm ",
2227
effects: [{ kind: "exec_js", encoded: "x" }],
@@ -26,7 +31,7 @@ describe("parseActionSpec", () => {
2631

2732
it("includes labelHtml when present", () => {
2833
expect(
29-
parseActionSpec({
34+
parseServerActionSpec({
3035
label: "<span>Open</span>",
3136
labelHtml: true,
3237
effects: [{ kind: "exec_js", encoded: "x" }],
@@ -35,10 +40,37 @@ describe("parseActionSpec", () => {
3540
});
3641

3742
it("returns null for invalid shape", () => {
38-
expect(parseActionSpec(null)).toBeNull();
39-
expect(parseActionSpec({ label: "" })).toBeNull();
40-
expect(parseActionSpec({ label: "X", effects: [] })).toBeNull();
41-
expect(parseActionSpec({ label: "X", effects: [{ kind: "other", encoded: "x" }] })).toBeNull();
43+
expect(parseServerActionSpec(null)).toBeNull();
44+
expect(parseServerActionSpec({ label: "" })).toBeNull();
45+
expect(parseServerActionSpec({ label: "X", effects: [] })).toBeNull();
46+
expect(
47+
parseServerActionSpec({ label: "X", effects: [{ kind: "other", encoded: "x" }] })
48+
).toBeNull();
49+
});
50+
});
51+
52+
describe("parseDomActionSpec", () => {
53+
it("rejects all action payloads including labelHtml and exec_js", () => {
54+
expect(parseDomActionSpec(null)).toBeNull();
55+
expect(
56+
parseDomActionSpec({
57+
label: "<img src=x onerror=alert(1)>",
58+
labelHtml: true,
59+
effects: [{ kind: "exec_js", encoded: "evil" }],
60+
})
61+
).toBeNull();
62+
expect(
63+
parseDomActionSpec({
64+
label: "Undo",
65+
effects: [{ kind: "exec_js", encoded: "abc" }],
66+
})
67+
).toBeNull();
68+
});
69+
});
70+
71+
describe("parseActionSpec", () => {
72+
it("aliases parseServerActionSpec", () => {
73+
expect(parseActionSpec).toBe(parseServerActionSpec);
4274
});
4375
});
4476

@@ -71,4 +103,48 @@ describe("Toast hook lifecycle", () => {
71103
expect(getToastStore(groupId)).toBeUndefined();
72104
expect(el.dataset.toastGroup).toBeUndefined();
73105
});
106+
107+
it("DOM toast:create ignores untrusted action payloads", () => {
108+
const el = document.createElement("div");
109+
el.id = groupId;
110+
document.body.appendChild(el);
111+
112+
const execJs = () => {
113+
throw new Error("execJs should not run");
114+
};
115+
116+
const { hook } = mockHookContext(el, {
117+
connected: false,
118+
overrides: {
119+
groupId: "",
120+
handlers: [] as CallbackRef[],
121+
domListeners: [] as Array<{ el: HTMLElement; name: string; fn: EventListener }>,
122+
js: () => ({ exec: execJs }),
123+
},
124+
});
125+
126+
callHookMounted(Toast, hook);
127+
128+
el.dispatchEvent(
129+
new CustomEvent("toast:create", {
130+
detail: {
131+
id: "dom-toast",
132+
title: "Hello",
133+
action: {
134+
label: '<img src=x onerror="window.__toastDomXss=1">',
135+
labelHtml: true,
136+
effects: [{ kind: "exec_js", encoded: "evil" }],
137+
},
138+
},
139+
})
140+
);
141+
142+
const action = el.querySelector<HTMLElement>(
143+
'[data-scope="toast"][data-part="action-trigger"]'
144+
);
145+
expect(action).toBeNull();
146+
147+
document.body.removeChild(el);
148+
callHookDestroyed(Toast, hook);
149+
});
74150
});

config/config.exs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import Config
22

3+
config :corex, mcp_verbose_errors: false
4+
35
config :logger, :console,
46
colors: [enabled: false],
57
format: "\n$time $metadata[$level] $message\n",

guides/MCP.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,27 @@ Verbose MCP logging:
114114
config :corex, debug: true
115115
```
116116

117+
## Security
118+
119+
| Setting | Default | Purpose |
120+
| ------- | ------- | ------- |
121+
| Loopback-only access | on | Non-loopback clients receive 403 unless you opt in |
122+
| `allow_remote_access: true` | off | Allows non-loopback IPs; logs a warning at plug init. Restrict with a firewall or VPN. Never use in production. |
123+
| `config :corex, mcp_verbose_errors: false` | off | Tool failures return a generic message to MCP clients; full exceptions stay in server logs |
124+
| `config :corex, debug: true` | off | Enables verbose MCP JSON-RPC debug logging on `Corex.MCP.Server` |
125+
126+
Example for local debugging only:
127+
128+
```elixir
129+
config :corex, debug: true, mcp_verbose_errors: true
130+
```
131+
132+
Remote access (discouraged outside trusted networks):
133+
134+
```elixir
135+
plug Corex.MCP, allow_remote_access: true
136+
```
137+
117138
## Related
118139

119140
- [Installation](installation.html)`mix corex.new` enables MCP in dev by default

installer/lib/corex_new/cli.ex

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,12 @@ defmodule Corex.New.Cli do
3232
:ok
3333

3434
v when is_binary(v) ->
35-
if String.trim(v) == "" do
35+
trimmed = String.trim(v)
36+
37+
if trimmed == "" do
3638
Mix.raise("--dev requires a non-empty path (for example: --dev ../corex)")
39+
else
40+
validate_dev_path!(trimmed)
3741
end
3842

3943
_ ->
@@ -111,4 +115,16 @@ defmodule Corex.New.Cli do
111115
Mix.raise("Please select another directory for installation.")
112116
end
113117
end
118+
119+
def validate_dev_path!(path) when is_binary(path) do
120+
if String.match?(path, ~r/["\r\n\x00]/) do
121+
Mix.raise("""
122+
--dev path contains invalid characters.
123+
124+
Provide a filesystem path without quotes or newlines.
125+
""")
126+
end
127+
128+
:ok
129+
end
114130
end

installer/lib/corex_new/generate.ex

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -330,6 +330,7 @@ defmodule Corex.New.Generate do
330330
trimmed = String.trim(path)
331331

332332
if trimmed != "" do
333+
Corex.New.Cli.validate_dev_path!(trimmed)
333334
corex_root = Path.expand(trimmed, install_dir)
334335
mjs = Path.join([corex_root, "priv", "static", "corex.mjs"])
335336

installer/lib/corex_new/patches.ex

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,8 @@ defmodule Corex.New.Patches do
436436
trimmed = String.trim(path)
437437

438438
if trimmed != "" do
439-
~s([path: "#{trimmed}", override: true])
439+
Corex.New.Cli.validate_dev_path!(trimmed)
440+
"[path: #{inspect(trimmed)}, override: true]"
440441
else
441442
corex_dep_constraint()
442443
end

installer/lib/corex_new/post_generate.ex

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,41 @@ defmodule Corex.New.PostGenerate do
66
def copy_cached_build(project_path) do
77
case System.fetch_env("COREX_NEW_CACHE_DIR") do
88
{:ok, cache_dir} ->
9+
cache_dir = validate_cache_dir!(cache_dir)
10+
911
if File.exists?(cache_dir) do
1012
Mix.shell().info("Copying cached build from #{cache_dir}")
11-
System.cmd("cp", ["-Rp", Path.join(cache_dir, "."), project_path])
13+
copy_tree!(cache_dir, project_path)
1214
end
1315

1416
:error ->
1517
:ok
1618
end
1719
end
1820

21+
defp validate_cache_dir!(cache_dir) do
22+
expanded = Path.expand(cache_dir)
23+
24+
cond do
25+
String.match?(cache_dir, ~r/["\r\n\x00]/) ->
26+
Mix.raise("COREX_NEW_CACHE_DIR contains invalid characters")
27+
28+
not File.dir?(expanded) ->
29+
Mix.raise("COREX_NEW_CACHE_DIR is not a directory: #{inspect(cache_dir)}")
30+
31+
true ->
32+
expanded
33+
end
34+
end
35+
36+
defp copy_tree!(source_dir, dest_dir) do
37+
File.mkdir_p!(dest_dir)
38+
39+
for entry <- File.ls!(source_dir) do
40+
File.cp_r!(Path.join(source_dir, entry), Path.join(dest_dir, entry))
41+
end
42+
end
43+
1944
def init_git(project_path) do
2045
if git_available?() and not inside_git_repo?(project_path) do
2146
Mix.shell().info([:green, "* initializing git repository", :reset])

installer/lib/mix/tasks/corex.new.ex

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -183,6 +183,9 @@ defmodule Mix.Tasks.Corex.New do
183183
true -> Module.concat([Macro.camelize(app) <> "Web"])
184184
end
185185

186+
check_module_name_validity!(web_module)
187+
check_module_name_availability!(web_module)
188+
186189
install_dir = PhxWrapper.web_project_path(phx_root, opts)
187190

188191
PhxWrapper.ensure_phx_new!()

0 commit comments

Comments
 (0)