Skip to content

Commit 2b6f540

Browse files
committed
fix(home-assistant): respect OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK
Home Assistant instances on a LAN (e.g. 10.0.0.5) were rejected by the SSRF egress guard even when the deployment set OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK, because the provider's executor definition never passed allowPrivateNetwork to defineProviderExecutors. Without that opt-in the runtime falls back to the shared public-only providerFetch, so the deployment flag had no effect for Home Assistant. Pass allowPrivateNetwork: isPrivateNetworkAccessAllowed, matching the 26 other providers that already wire it. Default behavior is unchanged: with the flag unset, private targets stay blocked. Loopback, link-local, and cloud-metadata targets remain blocked in both states. Cover the opt-in beside the shared module in provider-runtime.test.ts, per AGENTS.md ("Keep open-source-only shared-infrastructure tests beside the shared module rather than inside a provider directory").
1 parent fe7dd3c commit 2b6f540

2 files changed

Lines changed: 65 additions & 1 deletion

File tree

src/providers/home_assistant/executors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import type { CredentialValidators, ExecutionContext, ProviderExecutors } from "../../core/types.ts";
22
import type { HomeAssistantActionContext } from "./runtime.ts";
33

4+
import { isPrivateNetworkAccessAllowed } from "../../core/request.ts";
45
import { defineProviderExecutors, requireApiKeyCredential } from "../provider-runtime.ts";
56
import {
67
homeAssistantActionHandlers,
@@ -13,6 +14,7 @@ const service = "home_assistant";
1314
export const executors: ProviderExecutors = defineProviderExecutors<HomeAssistantActionContext>({
1415
service,
1516
handlers: homeAssistantActionHandlers,
17+
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
1618
async createContext(context: ExecutionContext, fetcher: typeof fetch): Promise<HomeAssistantActionContext> {
1719
const credential = await requireApiKeyCredential(context, service);
1820
return {

src/providers/provider-runtime.test.ts

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import type { ExecutionContext, ResolvedCredential } from "../core/types.ts";
22

33
import { afterEach, describe, expect, it, vi } from "vitest";
4-
import { setPrivateNetworkAccessAllowed } from "../core/request.ts";
4+
import { isPrivateNetworkAccessAllowed, setPrivateNetworkAccessAllowed } from "../core/request.ts";
55
import {
66
createProviderTimeout,
77
defineProviderExecutors,
@@ -136,6 +136,68 @@ describe("provider egress SSRF guard", () => {
136136
expect(response.status).toBe(302);
137137
expect(calls).toHaveLength(1);
138138
});
139+
140+
it("blocks private targets for executors that do not opt in", async () => {
141+
const calls = stubFetchSequence([new Response("{}", { status: 200 })]);
142+
const executors = defineProviderExecutors<{ fetcher: typeof fetch }>({
143+
service: "test_service",
144+
handlers: {
145+
async probe(_input, context) {
146+
return { status: (await context.fetcher("http://10.0.0.5:8123/api/")).status };
147+
},
148+
},
149+
createContext: (_context, fetcher) => ({ fetcher }),
150+
});
151+
setPrivateNetworkAccessAllowed(true);
152+
153+
const result = await executors["test_service.probe"]!({}, executionContext);
154+
155+
expect(result.ok).toBe(false);
156+
expect(result.error?.message).toContain("must not target private or reserved IP addresses");
157+
expect(calls).toHaveLength(0);
158+
});
159+
160+
it("reaches private targets for opted-in executors once the deployment enables the flag", async () => {
161+
const calls = stubFetchSequence([new Response("{}", { status: 200 })]);
162+
const executors = defineProviderExecutors<{ fetcher: typeof fetch }>({
163+
service: "test_service",
164+
handlers: {
165+
async probe(_input, context) {
166+
return { status: (await context.fetcher("http://10.0.0.5:8123/api/")).status };
167+
},
168+
},
169+
createContext: (_context, fetcher) => ({ fetcher }),
170+
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
171+
});
172+
setPrivateNetworkAccessAllowed(true);
173+
174+
const result = await executors["test_service.probe"]!({}, executionContext);
175+
176+
expect(result.ok).toBe(true);
177+
expect(calls.map((call) => call.url)).toEqual(["http://10.0.0.5:8123/api/"]);
178+
});
179+
180+
it("keeps the opt-in gated on the deployment flag and never unblocks loopback", async () => {
181+
const executors = defineProviderExecutors<{ fetcher: typeof fetch }>({
182+
service: "test_service",
183+
handlers: {
184+
async probe(_input, context) {
185+
return { status: (await context.fetcher(_input.url as string)).status };
186+
},
187+
},
188+
createContext: (_context, fetcher) => ({ fetcher }),
189+
allowPrivateNetwork: isPrivateNetworkAccessAllowed,
190+
});
191+
192+
const calls = stubFetchSequence([]);
193+
const disabled = await executors["test_service.probe"]!({ url: "http://10.0.0.5:8123/api/" }, executionContext);
194+
setPrivateNetworkAccessAllowed(true);
195+
const loopback = await executors["test_service.probe"]!({ url: "http://127.0.0.1:8123/api/" }, executionContext);
196+
197+
expect(disabled.ok).toBe(false);
198+
expect(loopback.ok).toBe(false);
199+
expect(calls).toHaveLength(0);
200+
});
139201
});
140202

141203
describe("provider runtime fetch", () => {

0 commit comments

Comments
 (0)