Skip to content

Commit fe347a4

Browse files
committed
fixed OAuth security dangers
1 parent ee2b17a commit fe347a4

2 files changed

Lines changed: 81 additions & 34 deletions

File tree

api/src/controllers/auth.controller.js

Lines changed: 65 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -162,17 +162,10 @@ function sanitizeReturnTo(raw, mode = "login", req, enforceAllowedOrigins = true
162162
if (!["http:", "https:"].includes(parsed.protocol)) return fallback;
163163
if (enforceAllowedOrigins) {
164164
const allowedOrigins = new Set(env.clientOrigins || []);
165-
const originHeader = String(req?.headers?.origin || "").trim();
166-
const refererHeader = String(req?.headers?.referer || "").trim();
167-
if (originHeader) allowedOrigins.add(originHeader);
168-
if (refererHeader) {
169-
try {
170-
allowedOrigins.add(new URL(refererHeader).origin);
171-
} catch {
172-
// ignore invalid referer
173-
}
165+
const allowLoopback = isLoopbackHost(parsed.hostname);
166+
if (!allowLoopback && (allowedOrigins.size === 0 || !allowedOrigins.has(parsed.origin))) {
167+
return fallback;
174168
}
175-
if (allowedOrigins.size > 0 && !allowedOrigins.has(parsed.origin)) return fallback;
176169
}
177170
return parsed.toString();
178171
} catch {
@@ -189,12 +182,60 @@ function appendUrlParams(url, params = {}) {
189182
return target.toString();
190183
}
191184

185+
function appendUrlHashParams(url, params = {}) {
186+
const target = new URL(url);
187+
const hashParams = new URLSearchParams(target.hash.startsWith("#") ? target.hash.slice(1) : "");
188+
for (const [key, value] of Object.entries(params)) {
189+
if (value === undefined || value === null || value === "") continue;
190+
hashParams.set(key, String(value));
191+
}
192+
target.hash = hashParams.toString();
193+
return target.toString();
194+
}
195+
196+
function signOauthStatePayload(payloadB64) {
197+
return crypto.createHmac("sha256", env.jwtSecret).update(payloadB64).digest("base64url");
198+
}
199+
200+
function createOauthState({ nonce, mode, returnTo }) {
201+
const payload = Buffer.from(
202+
JSON.stringify({
203+
nonce: String(nonce || ""),
204+
mode: mode === "register" ? "register" : "login",
205+
returnTo: String(returnTo || ""),
206+
}),
207+
"utf8"
208+
).toString("base64url");
209+
const sig = signOauthStatePayload(payload);
210+
return `${payload}.${sig}`;
211+
}
212+
213+
function parseOauthState(rawState) {
214+
const raw = String(rawState || "");
215+
if (!raw) return null;
216+
const [payload, sig] = raw.split(".");
217+
if (!payload || !sig) return null;
218+
219+
const expectedSig = signOauthStatePayload(payload);
220+
const sigBuf = Buffer.from(sig, "utf8");
221+
const expectedBuf = Buffer.from(expectedSig, "utf8");
222+
if (sigBuf.length !== expectedBuf.length) return null;
223+
if (!crypto.timingSafeEqual(sigBuf, expectedBuf)) return null;
224+
225+
try {
226+
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"));
227+
} catch {
228+
return null;
229+
}
230+
}
231+
192232
function setOauthStateCookie(res, value) {
193233
const isProd = env.nodeEnv === "production";
194234
res.cookie("oauth_state", value, {
195235
httpOnly: true,
196236
secure: isProd,
197237
sameSite: "lax",
238+
path: "/api/auth/google",
198239
maxAge: 10 * 60 * 1000,
199240
});
200241
}
@@ -205,6 +246,7 @@ function clearOauthStateCookie(res) {
205246
httpOnly: true,
206247
secure: isProd,
207248
sameSite: "lax",
249+
path: "/api/auth/google",
208250
expires: new Date(0),
209251
});
210252
}
@@ -340,7 +382,7 @@ export const googleStart = asyncHandler(async (req, res) => {
340382
const mode = req.query?.mode === "register" ? "register" : "login";
341383
const returnTo = sanitizeReturnTo(req.query?.returnTo, mode, req, true);
342384
const nonce = crypto.randomUUID();
343-
const state = Buffer.from(JSON.stringify({ nonce, mode, returnTo }), "utf8").toString("base64url");
385+
const state = createOauthState({ nonce, mode, returnTo });
344386

345387
setOauthStateCookie(res, nonce);
346388

@@ -358,20 +400,15 @@ export const googleStart = asyncHandler(async (req, res) => {
358400
});
359401

360402
export const googleCallback = asyncHandler(async (req, res) => {
361-
const rawState = String(req.query?.state || "");
362-
363-
let decodedState = { nonce: "", mode: "login", returnTo: getDefaultFrontendAuthUrl("login") };
364-
if (rawState) {
365-
try {
366-
decodedState = JSON.parse(Buffer.from(rawState, "base64url").toString("utf8"));
367-
} catch {
368-
// keep fallback defaults
369-
}
370-
}
403+
const decodedState = parseOauthState(req.query?.state) || {
404+
nonce: "",
405+
mode: "login",
406+
returnTo: getDefaultFrontendAuthUrl("login"),
407+
};
371408

372409
const mode = decodedState.mode === "register" ? "register" : "login";
373-
const returnTo = sanitizeReturnTo(decodedState.returnTo, mode, req, false);
374-
const failRedirect = (message) => res.redirect(appendUrlParams(returnTo, { auth_error: message }));
410+
const returnTo = sanitizeReturnTo(decodedState.returnTo, mode, req, true);
411+
const failRedirect = (message) => res.redirect(appendUrlHashParams(returnTo, { auth_error: message }));
375412
const googleRedirectUri = getGoogleRedirectUri(req);
376413

377414
if (!isGoogleAuthConfigured(req)) {
@@ -419,13 +456,11 @@ export const googleCallback = asyncHandler(async (req, res) => {
419456
req,
420457
});
421458

422-
return res.redirect(
423-
appendUrlParams(returnTo, {
424-
auth_success: "1",
425-
auth_mode: mode,
426-
auth_token: token,
427-
})
428-
);
459+
return res.redirect(appendUrlHashParams(returnTo, {
460+
auth_success: "1",
461+
auth_mode: mode,
462+
auth_token: token,
463+
}));
429464
} catch (err) {
430465
console.error("Google auth callback failed:", err);
431466
return failRedirect(err?.message || "Google authentication failed");

web/scripts/api.js

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,17 @@ export const auth = {
195195

196196
consumeGoogleRedirect() {
197197
const currentUrl = new URL(window.location.href);
198-
const token = currentUrl.searchParams.get("auth_token") || "";
199-
const success = currentUrl.searchParams.get("auth_success") === "1";
200-
const error = currentUrl.searchParams.get("auth_error") || "";
201-
const mode = currentUrl.searchParams.get("auth_mode") || "";
198+
const hashParams = new URLSearchParams(
199+
currentUrl.hash.startsWith("#") ? currentUrl.hash.slice(1) : ""
200+
);
201+
const token =
202+
hashParams.get("auth_token") || currentUrl.searchParams.get("auth_token") || "";
203+
const success =
204+
(hashParams.get("auth_success") || currentUrl.searchParams.get("auth_success")) === "1";
205+
const error =
206+
hashParams.get("auth_error") || currentUrl.searchParams.get("auth_error") || "";
207+
const mode =
208+
hashParams.get("auth_mode") || currentUrl.searchParams.get("auth_mode") || "";
202209

203210
if (token) {
204211
setAuthToken(token);
@@ -209,6 +216,11 @@ export const auth = {
209216
currentUrl.searchParams.delete("auth_success");
210217
currentUrl.searchParams.delete("auth_error");
211218
currentUrl.searchParams.delete("auth_mode");
219+
hashParams.delete("auth_token");
220+
hashParams.delete("auth_success");
221+
hashParams.delete("auth_error");
222+
hashParams.delete("auth_mode");
223+
currentUrl.hash = hashParams.toString();
212224
window.history.replaceState({}, document.title, currentUrl.toString());
213225
}
214226

0 commit comments

Comments
 (0)