Merge remote-tracking branch 'origin/work/session-secret-and-nightscout' into release/v1.30.33

This commit is contained in:
Marc-André Bombeck committed 2026-07-19 11:59:44 +02:00
commit 03fdcddb92
14 files changed
+713 -60

No files matched your search

@@ -140,6 +140,63 @@ describe("POST /api/nightscout/connect", () => {
expect(userUpdate).not.toHaveBeenCalled();
});
// Input-time SSRF floor. Every case below leaves the default happy-path
// `fetchSgvEntries` mock in place — it RESOLVES — so the only thing that
// can produce a 422 is the check that runs before the probe. Asserting the
// probe was never called is what separates this from the fetch-time guard:
// a URL that never reaches the egress layer cannot have been refused by it.
describe("refuses private / non-public hosts before the probe runs", () => {
const cases: Array<[string, string]> = [
["loopback", "http://127.0.0.1:1337"],
["loopback by name", "http://localhost:1337"],
["link-local", "http://169.254.1.1"],
["cloud metadata endpoint", "http://169.254.169.254/latest/meta-data/"],
["RFC1918 class A", "http://10.0.0.5"],
["RFC1918 class C", "http://192.168.1.5"],
["CGNAT", "http://100.64.0.1"],
["IPv4-mapped IPv6 loopback", "http://[::ffff:127.0.0.1]"],
["IPv6 loopback", "http://[::1]:1337"],
["IPv6 unique-local", "http://[fd00::1]"],
["octal IPv4 loopback", "http://0177.0.0.1"],
["hex IPv4 loopback", "http://0x7f000001"],
["decimal IPv4 loopback", "http://2130706433"],
[".internal suffix", "http://cgm.internal"],
[".local suffix", "http://cgm.local"],
];
it.each(cases)("refuses %s", async (_label, url) => {
const res = await post(req({ url, token: "tok" }));
expect(res.status).toBe(422);
expect(String(res.error)).toMatch(/private network/i);
expect(fetchSgvEntriesMock).not.toHaveBeenCalled();
expect(userUpdate).not.toHaveBeenCalled();
});
it("still lets the explicit self-hoster opt-in through to the probe", async () => {
// The floor is gated, not absolute: a LAN instance stays reachable for
// the operator who asked for it. Without this the opt-in is dead code.
const res = await post(
req({
url: "http://192.168.1.5:1337",
token: "tok",
allowPrivateHost: true,
}),
);
expect(res.status).toBe(200);
expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1);
expect(userUpdate).toHaveBeenCalledTimes(1);
});
it("lets an ordinary public instance through", async () => {
const res = await post(req({ url: "https://ns.example.com" }));
expect(res.status).toBe(200);
expect(fetchSgvEntriesMock).toHaveBeenCalledTimes(1);
});
});
it("rate-limits the connect surface", async () => {
rateLimitMock.mockResolvedValue({
allowed: false,
+20
View File
@@ -12,6 +12,7 @@ import { markReconnected } from "@/lib/integrations/status";
import { SafeFetchError } from "@/lib/safe-fetch";
import { fetchSgvEntries, NightscoutApiError } from "@/lib/nightscout/client";
import { nightscoutConnectSchema } from "@/lib/validations/nightscout";
import { isPublicUrl } from "@/lib/validations/notifications";
/**
* Connect (or update) the user's Nightscout instance (v1.17.0).
@@ -49,6 +50,25 @@ export const POST = apiHandler(async (request: NextRequest) => {
const { url, token, allowPrivateHost } = result.data;
// SSRF floor at INPUT time, not merely as a side effect of the live probe
// below. `fetchSgvEntries` does route through `safeFetch` with the same
// public-host pin, so today both paths refuse the same URLs — but that
// makes the guard on the value we PERSIST contingent on the probe staying
// exactly where it is. Reorder it, make it best-effort, or add a "save
// without testing" affordance and the stored URL is suddenly unchecked.
// Assert it here so the thing that gets encrypted onto the row is the
// thing that was validated.
//
// Gated on the same explicit opt-in the fetch path uses: a self-hoster who
// deliberately points at a LAN instance has asked for the private host and
// must still be able to connect.
if (!allowPrivateHost && !isPublicUrl(url)) {
return apiError(
"That instance is on a private network. Enable the private-network option to connect to it.",
422,
);
}
// Live validation: pull a single SGV entry. A connection that can't even
// fetch one row is not worth storing.
try {