Skip to content

Commit 962d735

Browse files
authored
fix(home-assistant): respect OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK (#220)
## Summary - Home Assistant connections to a LAN instance fail with `request URL must not target private or reserved IP addresses`, even when the deployment sets `OOMOL_CONNECT_ALLOW_PRIVATE_NETWORK`. - `src/providers/home_assistant/executors.ts` never passed `allowPrivateNetwork` to `defineProviderExecutors`, so the flag had no effect for this provider. - Adds the one-line opt-in, matching the 26 other providers that already wire it. ## Problem `defineProviderExecutors` builds a private-network-aware egress fetch only when a provider opts in; otherwise it falls back to the public-only `providerFetch`. Home Assistant took that fallback, so the flag could never reach a `192.168.x.x` or `10.x.x.x` instance — the self-hosted case it exists for. `homeassistant.local:8123`, the placeholder shown in the connection form, was unreachable for the same reason. Default behavior is unchanged: with the flag unset, private targets stay blocked. Loopback, link-local, and cloud-metadata targets stay blocked either way. ## Scope Only the executor needed wiring. Home Assistant has no `assertPublicHttpUrl` call site to thread the flag into `resolveHomeAssistantBaseUrl` and `validateHomeAssistantCredential` both route through the provider-local `normalizeBaseUrl` — and no `proxy.registry.ts` entry. Migrating `normalizeBaseUrl` onto the shared `assertPublicHttpUrl` is a separate change, not attempted here. ## Tests Added to `src/providers/provider-runtime.test.ts` rather than a provider-local file, per AGENTS.md: "Keep open-source-only shared-infrastructure tests beside the shared module rather than inside a provider directory." Three cases cover the shared opt-in path: default blocks a private target, an opted-in executor reaches it once the flag is enabled, and loopback stays blocked even then. ## Verification `npm run fix-check` (clean) and `npm test` — 57 files, 566 tests passing.
1 parent 8dfd8de commit 962d735

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)