mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
feat(withings): rate-limit connect + redirect on row-create failure
The connect endpoint minted a fresh OAuth state row per call with no guard. Add checkRateLimit (10 calls / 60 s per user) mirroring disable-coach. Wrap the row create in try/catch — a DB-glitch now redirects to /settings/integrations?withings=error&reason=connect instead of bubbling a 500. Closes L5 + L14 in v1.4.48 backlog.
This commit is contained in:
1 parent
0564d86373
commit
7c31e51fe4
2 files changed
+186
-10
No files matched your search
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* v1.4.48 L5 + L14 — GET /api/withings/connect
|
||||
*
|
||||
* Pins:
|
||||
* 1. Happy path: mints a nonce, creates a `WithingsOAuthState` row,
|
||||
* redirects to the Withings authorize URL with the state cookie
|
||||
* attached.
|
||||
* 2. Rate-limit: the 11th call in a 60 s window redirects to
|
||||
* `/settings/integrations?withings=error&reason=rate_limited`
|
||||
* and does not call `prisma.withingsOAuthState.create`.
|
||||
* 3. Row-create failure: a Prisma error inside the try/catch
|
||||
* redirects to
|
||||
* `/settings/integrations?withings=error&reason=connect`
|
||||
* instead of bubbling a 500.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
vi.mock("@/lib/api-handler", () => ({
|
||||
apiHandler: <T extends (...args: unknown[]) => unknown>(fn: T) => fn,
|
||||
requireAuth: vi.fn(async () => ({
|
||||
user: { id: "u-1" },
|
||||
session: { id: "s-1" },
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/db", () => ({
|
||||
prisma: {
|
||||
withingsOAuthState: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/rate-limit", () => ({
|
||||
checkRateLimit: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/credentials", () => ({
|
||||
getUserWithingsCredentials: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/client", () => ({
|
||||
getAuthorizationUrl: vi.fn(
|
||||
() => "https://account.withings.com/oauth2_user/authorize2?state=nonce-stub",
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/withings/oauth-state", () => ({
|
||||
WITHINGS_OAUTH_STATE_COOKIE: "withings_oauth_state",
|
||||
WITHINGS_OAUTH_STATE_TTL_MS: 10 * 60 * 1000,
|
||||
mintWithingsOAuthStateNonce: vi.fn(() => "nonce-stub"),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/logging/context", () => ({
|
||||
annotate: vi.fn(),
|
||||
getEvent: vi.fn(() => ({
|
||||
setError: vi.fn(),
|
||||
addWarning: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
import { GET } from "../route";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
|
||||
function connectRequest(): NextRequest {
|
||||
return new NextRequest("http://localhost/api/withings/connect", {
|
||||
method: "GET",
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValue({
|
||||
allowed: true,
|
||||
remaining: 9,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
vi.mocked(prisma.withingsOAuthState.create).mockReset();
|
||||
vi.mocked(prisma.withingsOAuthState.create).mockResolvedValue(
|
||||
{} as never,
|
||||
);
|
||||
vi.mocked(getUserWithingsCredentials).mockResolvedValue({
|
||||
clientId: "client-id",
|
||||
clientSecret: "client-secret",
|
||||
} as never);
|
||||
});
|
||||
|
||||
describe("GET /api/withings/connect", () => {
|
||||
it("redirects to the Withings authorize URL on the happy path", async () => {
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
expect(response.headers.get("location")).toContain(
|
||||
"account.withings.com/oauth2_user/authorize2",
|
||||
);
|
||||
expect(prisma.withingsOAuthState.create).toHaveBeenCalledTimes(1);
|
||||
expect(checkRateLimit).toHaveBeenCalledWith(
|
||||
"withings:connect:u-1",
|
||||
10,
|
||||
60_000,
|
||||
);
|
||||
});
|
||||
|
||||
it("redirects to reason=rate_limited when the 11th call within 60 s is rejected", async () => {
|
||||
vi.mocked(checkRateLimit).mockResolvedValueOnce({
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetAt: Date.now() + 60_000,
|
||||
} as never);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("withings=error");
|
||||
expect(location).toContain("reason=rate_limited");
|
||||
expect(prisma.withingsOAuthState.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("redirects to reason=connect when the row create throws", async () => {
|
||||
vi.mocked(prisma.withingsOAuthState.create).mockRejectedValueOnce(
|
||||
new Error("Prisma transient failure"),
|
||||
);
|
||||
|
||||
const response = await GET(connectRequest());
|
||||
|
||||
expect(response.status).toBe(307);
|
||||
const location = response.headers.get("location") ?? "";
|
||||
expect(location).toContain("/settings/integrations");
|
||||
expect(location).toContain("withings=error");
|
||||
expect(location).toContain("reason=connect");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import { apiHandler, requireAuth } from "@/lib/api-handler";
|
||||
import { apiError } from "@/lib/api-response";
|
||||
import { prisma } from "@/lib/db";
|
||||
import { annotate } from "@/lib/logging/context";
|
||||
import { annotate, getEvent } from "@/lib/logging/context";
|
||||
import { checkRateLimit } from "@/lib/rate-limit";
|
||||
import { getAuthorizationUrl } from "@/lib/withings/client";
|
||||
import { getUserWithingsCredentials } from "@/lib/withings/credentials";
|
||||
import {
|
||||
@@ -9,7 +10,7 @@ import {
|
||||
WITHINGS_OAUTH_STATE_TTL_MS,
|
||||
mintWithingsOAuthStateNonce,
|
||||
} from "@/lib/withings/oauth-state";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
/**
|
||||
* Redirects the user to Withings OAuth authorization page.
|
||||
@@ -21,11 +22,39 @@ import { NextResponse } from "next/server";
|
||||
* nonce, and the callback resolves the user via the row's `userId`
|
||||
* column. Closes the v1.4.43 security audit L-1 finding where the
|
||||
* user id was recoverable from log entries / network captures.
|
||||
*
|
||||
* v1.4.48 L5 + L14 — endpoint is now rate-limited per-user (10 calls
|
||||
* per 60 s) so a logged-in user with valid creds can't spam ledger
|
||||
* rows for the full 10-min TTL window. The row-create call is wrapped
|
||||
* in try/catch; a transient DB glitch now lands on the existing
|
||||
* `/settings/integrations?withings=error&reason=connect` surface
|
||||
* instead of bubbling a 500. Both rate-limit and create-failure paths
|
||||
* redirect rather than returning JSON because the entry point is a
|
||||
* browser navigation — a 429 envelope would surface as a blank page.
|
||||
*/
|
||||
export const GET = apiHandler(async () => {
|
||||
|
||||
const CONNECT_RATE_LIMIT = 10;
|
||||
const CONNECT_WINDOW_MS = 60_000;
|
||||
|
||||
export const GET = apiHandler(async (req: NextRequest) => {
|
||||
const { user } = await requireAuth();
|
||||
annotate({ action: { name: "withings.connect" } });
|
||||
|
||||
const rl = await checkRateLimit(
|
||||
`withings:connect:${user.id}`,
|
||||
CONNECT_RATE_LIMIT,
|
||||
CONNECT_WINDOW_MS,
|
||||
);
|
||||
if (!rl.allowed) {
|
||||
annotate({ action: { name: "withings.connect.rate_limited" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=rate_limited",
|
||||
req.url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const creds = await getUserWithingsCredentials(user.id);
|
||||
if (!creds) {
|
||||
return apiError(
|
||||
@@ -35,13 +64,24 @@ export const GET = apiHandler(async () => {
|
||||
}
|
||||
|
||||
const nonce = mintWithingsOAuthStateNonce();
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
|
||||
},
|
||||
});
|
||||
try {
|
||||
await prisma.withingsOAuthState.create({
|
||||
data: {
|
||||
nonce,
|
||||
userId: user.id,
|
||||
expiresAt: new Date(Date.now() + WITHINGS_OAUTH_STATE_TTL_MS),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
getEvent()?.setError(err);
|
||||
annotate({ action: { name: "withings.connect.create_failed" } });
|
||||
return NextResponse.redirect(
|
||||
new URL(
|
||||
"/settings/integrations?withings=error&reason=connect",
|
||||
req.url,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
const url = getAuthorizationUrl(nonce, creds);
|
||||
|
||||
|
||||
Reference in new issue
Block a user