Skip to content

Commit 3ef012f

Browse files
committed
bluebubbles: close SSRF bypass when user opts out of private network (aisle #68234 HIGH)
Aisle security analysis flagged `resolveBlueBubblesClientSsrfPolicy` mode 3 as an SSRF bypass: it returned `ssrfPolicy: undefined`, which `blueBubblesFetchWithTimeout` treats as a signal to skip `fetchWithSsrFGuard` and call `fetch()` directly. Mode 3 fires in two cases: A. Private hostname + user explicitly opted out (`network.dangerouslyAllowPrivateNetwork: false`). The user asked us to block private networks and we instead sent the request via the unguarded fallback path — a real bypass. B. Unparseable baseUrl. Would route through the unguarded path (cosmetic in practice since the fetch would fail elsewhere, but still wrong). Fix: return `ssrfPolicy: {}` (default-deny guarded policy) instead of `undefined`. All three modes now go through `fetchWithSsrFGuard`: 1. `{ allowPrivateNetwork: true }` — user opted in 2. `{ allowedHostnames: [host] }` — narrow allowlist for trusted host 3. `{}` — default-deny, honors opt-out Tightened `ssrfPolicy` field/param types from `SsrFPolicy | undefined` to `SsrFPolicy` so the bypass cannot be reintroduced by a future caller handing back `undefined`. Added a mode-3 invariant regression test that walks every resolution case and asserts `ssrfPolicy` is always defined (caught any future `undefined` reintroduction). Updated `attachments.test.ts` case that was asserting the old (buggy) `undefined` behavior. Existing-user impact: only users with `dangerouslyAllowPrivateNetwork: false` set on a private-address BB server see a behavior change (they now get a guarded refusal instead of an accidental success). That's the correct posture per their config.
1 parent 6dfb043 commit 3ef012f

3 files changed

Lines changed: 47 additions & 13 deletions

File tree

extensions/bluebubbles/src/attachments.test.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,8 +341,12 @@ describe("downloadBlueBubblesAttachment", () => {
341341
},
342342
});
343343

344+
// Default-deny policy via the guard, NOT unguarded fetch. Aisle #68234
345+
// flagged the previous `undefined` fallback as a real SSRF bypass because
346+
// `blueBubblesFetchWithTimeout` treats `undefined` as "skip the SSRF
347+
// guard entirely", exactly when the user asked us to block private nets.
344348
const fetchMediaArgs = fetchRemoteMediaMock.mock.calls[0][0] as Record<string, unknown>;
345-
expect(fetchMediaArgs.ssrfPolicy).toBeUndefined();
349+
expect(fetchMediaArgs.ssrfPolicy).toEqual({});
346350
});
347351

348352
it("allowlists public serverUrl hostname when allowPrivateNetwork is not set", async () => {

extensions/bluebubbles/src/client.test.ts

Lines changed: 27 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -115,24 +115,47 @@ describe("resolveBlueBubblesClientSsrfPolicy (3-mode policy)", () => {
115115
expect(result.trustedHostnameIsPrivate).toBe(false);
116116
});
117117

118-
it("mode 3: private hostname + explicit opt-out → undefined (falls back to non-SSRF path)", () => {
118+
it("mode 3: private hostname + explicit opt-out → {} (guarded default-deny, honors the opt-out) (aisle #68234)", () => {
119+
// Previously returned `undefined`, which routed through the unguarded
120+
// fetch fallback and effectively bypassed SSRF protection exactly when
121+
// the user had explicitly asked to disable private-network access.
119122
const result = resolveBlueBubblesClientSsrfPolicy({
120123
baseUrl: "http://192.168.1.50:1234",
121124
allowPrivateNetwork: false,
122125
allowPrivateNetworkConfig: false,
123126
});
124-
expect(result.ssrfPolicy).toBeUndefined();
127+
expect(result.ssrfPolicy).toEqual({});
125128
expect(result.trustedHostnameIsPrivate).toBe(true);
126129
});
127130

128-
it("mode 3: unparseable baseUrl → undefined policy", () => {
131+
it("mode 3: unparseable baseUrl → {} (fail-safe guarded, never bypass)", () => {
129132
const result = resolveBlueBubblesClientSsrfPolicy({
130133
baseUrl: "not a url",
131134
allowPrivateNetwork: false,
132135
});
133-
expect(result.ssrfPolicy).toBeUndefined();
136+
expect(result.ssrfPolicy).toEqual({});
134137
expect(result.trustedHostname).toBeUndefined();
135138
});
139+
140+
it("never returns undefined ssrfPolicy — every mode is guarded (aisle #68234 invariant)", () => {
141+
// This invariant is what closes the SSRF bypass aisle flagged. Any
142+
// refactor that reintroduces `ssrfPolicy: undefined` should break here.
143+
const cases = [
144+
{ baseUrl: "http://localhost:1234", allowPrivateNetwork: true },
145+
{ baseUrl: "http://localhost:1234", allowPrivateNetwork: false },
146+
{
147+
baseUrl: "http://192.168.1.50:1234",
148+
allowPrivateNetwork: false,
149+
allowPrivateNetworkConfig: false,
150+
},
151+
{ baseUrl: "https://bb.example.com", allowPrivateNetwork: false },
152+
{ baseUrl: "not a url", allowPrivateNetwork: false },
153+
];
154+
for (const c of cases) {
155+
const result = resolveBlueBubblesClientSsrfPolicy(c);
156+
expect(result.ssrfPolicy).toBeDefined();
157+
}
158+
});
136159
});
137160

138161
// --- Auth strategies -------------------------------------------------------

extensions/bluebubbles/src/client.ts

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,9 @@ function safeExtractHostname(baseUrl: string): string | undefined {
8787
}
8888

8989
/**
90-
* Resolve the BB client's SSRF policy at construction time. Three modes:
90+
* Resolve the BB client's SSRF policy at construction time. Three modes —
91+
* all of which go through `fetchWithSsrFGuard`; we never hand back a policy
92+
* that skips the guard:
9193
*
9294
* 1. `{ allowPrivateNetwork: true }` — user explicitly opted in
9395
* (`network.dangerouslyAllowPrivateNetwork: true`). Private/loopback
@@ -99,8 +101,11 @@ function safeExtractHostname(baseUrl: string): string | undefined {
99101
* that closes #34749, #57181, #59722, #60715 for self-hosted BB on
100102
* private/localhost addresses without requiring a full opt-in.
101103
*
102-
* 3. `undefined` — no policy; use the non-SSRF fallback path. Applied only
103-
* when we can't identify a trusted hostname. (#64105)
104+
* 3. `{}` — guarded with the default-deny policy. Applied when we can't
105+
* produce a valid allowlist (opt-out on a private hostname, or an
106+
* unparseable baseUrl). Previously returned `undefined` and skipped
107+
* the guard entirely, which was an SSRF bypass when a user explicitly
108+
* opted out of private-network access. Aisle #68234 found this.
104109
*
105110
* Prior to this helper, the logic lived inline in `attachments.ts` and was
106111
* inconsistently replicated across 15+ callsites. Resolving once ensures
@@ -111,7 +116,7 @@ export function resolveBlueBubblesClientSsrfPolicy(params: {
111116
allowPrivateNetwork: boolean;
112117
allowPrivateNetworkConfig?: boolean;
113118
}): {
114-
ssrfPolicy: SsrFPolicy | undefined;
119+
ssrfPolicy: SsrFPolicy;
115120
trustedHostname?: string;
116121
trustedHostnameIsPrivate: boolean;
117122
} {
@@ -137,7 +142,9 @@ export function resolveBlueBubblesClientSsrfPolicy(params: {
137142
};
138143
}
139144

140-
return { ssrfPolicy: undefined, trustedHostname, trustedHostnameIsPrivate };
145+
// Mode 3: default-deny guard. Honors an explicit opt-out on a private
146+
// hostname and fails-safe on unparseable URLs. Never undefined. (aisle #68234)
147+
return { ssrfPolicy: {}, trustedHostname, trustedHostnameIsPrivate };
141148
}
142149

143150
// --- Client ----------------------------------------------------------------
@@ -155,7 +162,7 @@ type ClientConstructorParams = {
155162
accountId: string;
156163
baseUrl: string;
157164
password: string;
158-
ssrfPolicy: SsrFPolicy | undefined;
165+
ssrfPolicy: SsrFPolicy;
159166
trustedHostname: string | undefined;
160167
trustedHostnameIsPrivate: boolean;
161168
defaultTimeoutMs: number;
@@ -181,7 +188,7 @@ export class BlueBubblesClient {
181188
readonly trustedHostnameIsPrivate: boolean;
182189

183190
private readonly password: string;
184-
private readonly ssrfPolicy: SsrFPolicy | undefined;
191+
private readonly ssrfPolicy: SsrFPolicy;
185192
private readonly defaultTimeoutMs: number;
186193
private readonly authStrategy: BlueBubblesAuthStrategy;
187194

@@ -200,7 +207,7 @@ export class BlueBubblesClient {
200207
* Read the resolved SSRF policy for this client. Exposed primarily for tests
201208
* and diagnostics; production code should never need to inspect it.
202209
*/
203-
getSsrfPolicy(): SsrFPolicy | undefined {
210+
getSsrfPolicy(): SsrFPolicy {
204211
return this.ssrfPolicy;
205212
}
206213

0 commit comments

Comments
 (0)