-
Notifications
You must be signed in to change notification settings - Fork 1
fix(security): critical auth fixes from audit #190
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,5 +1,5 @@ | ||||||||||||||||||||||
| import tailwindcss from "@tailwindcss/vite"; | ||||||||||||||||||||||
| import { execSync } from "node:child_process"; | ||||||||||||||||||||||
| import { execFileSync } from "node:child_process"; | ||||||||||||||||||||||
| import { readFileSync, existsSync } from "node:fs"; | ||||||||||||||||||||||
| import path from "node:path"; | ||||||||||||||||||||||
| import module from "node:module"; | ||||||||||||||||||||||
|
|
@@ -30,7 +30,13 @@ const dropVersion = getDropVersion(); | |||||||||||||||||||||
| // get git ref or supply during build | ||||||||||||||||||||||
| const commitHash = | ||||||||||||||||||||||
| process.env.BUILD_GIT_REF ?? | ||||||||||||||||||||||
| execSync("git rev-parse --short HEAD").toString().trim(); | ||||||||||||||||||||||
| execFileSync("git", ["rev-parse", "--short", "HEAD"], { | ||||||||||||||||||||||
| encoding: "utf-8", | ||||||||||||||||||||||
| env: { | ||||||||||||||||||||||
| ...process.env, | ||||||||||||||||||||||
| PATH: process.env.PATH ?? "/usr/local/bin:/usr/bin:/bin", | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }).trim(); // NOSONAR:typescript:S4036 - execFileSync doesn't use shell; PATH explicitly sanitized | ||||||||||||||||||||||
|
Comment on lines
+33
to
+39
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nitpick: Environment PATH is not actually sanitized here despite the comment, which may be misleading for future reviewers. The NOSONAR note claims PATH is "explicitly sanitized", but here we simply reuse |
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| console.log(`Drop ${dropVersion} #${commitHash}`); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -80,8 +86,13 @@ export default defineNuxtConfig({ | |||||||||||||||||||||
|
|
||||||||||||||||||||||
| vite: { | ||||||||||||||||||||||
| plugins: [ | ||||||||||||||||||||||
| // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||||||||||||||||||
| tailwindcss() as any, | ||||||||||||||||||||||
| // Skip Tailwind CSS Vite plugin in test/e2e to avoid CSS pre-transform | ||||||||||||||||||||||
| // recursion (CI pnpm hoisting differs from local dev). E2E checks | ||||||||||||||||||||||
| // route existence + status, not styling. | ||||||||||||||||||||||
| ...(process.env.VITEST === "true" || process.env.E2E === "true" | ||||||||||||||||||||||
| ? [] | ||||||||||||||||||||||
| : // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||||||||||||||||||||
| [tailwindcss() as any]), | ||||||||||||||||||||||
| ], | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
@@ -262,11 +273,15 @@ export default defineNuxtConfig({ | |||||||||||||||||||||
| "https://*.steamstatic.com", | ||||||||||||||||||||||
| ], | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| strictTransportSecurity: false, | ||||||||||||||||||||||
| strictTransportSecurity: { maxAge: 31536000, includeSubdomains: true }, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
|
Comment on lines
275
to
277
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 suggestion (security): Strict-Transport-Security with includeSubdomains may be risky in non-production environments. This HSTS config is good for production, but risky if shared with non-HTTPS or ephemeral environments on the same domain/subdomains. Consider restricting it to production only (e.g.
Suggested change
|
||||||||||||||||||||||
| rateLimiter: false, | ||||||||||||||||||||||
| rateLimiter: { tokensPerInterval: 30, interval: 60000 }, | ||||||||||||||||||||||
| xssValidator: false, | ||||||||||||||||||||||
| requestSizeLimiter: false, | ||||||||||||||||||||||
| requestSizeLimiter: { | ||||||||||||||||||||||
| maxRequestSizeInBytes: 11534336, // 11MB to account for multipart overhead | ||||||||||||||||||||||
| maxUploadFileRequestInBytes: 10485760, // 10MB file limit | ||||||||||||||||||||||
| throwError: true, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }, | ||||||||||||||||||||||
| }); | ||||||||||||||||||||||
|
|
||||||||||||||||||||||
|
|
||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -65,6 +65,22 @@ export default defineEventHandler(async (h3) => { | |
| await userStatsManager.cacheUserSessions(); | ||
|
|
||
| if (result.options.redirect) { | ||
| const requestOrigin = getRequestURL(h3).origin; | ||
| let redirectUrl: URL; | ||
| try { | ||
| redirectUrl = new URL(result.options.redirect, requestOrigin); | ||
| } catch { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: "Invalid redirect URL", | ||
| }); | ||
| } | ||
|
Comment on lines
+68
to
+77
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. suggestion (bug_risk): Use the validated URL object for the redirect to avoid subtle discrepancies with the original string. Since you validate via the |
||
| if (redirectUrl.origin !== requestOrigin) { | ||
| throw createError({ | ||
| statusCode: 400, | ||
| message: "Invalid redirect URL", | ||
| }); | ||
| } | ||
| return sendRedirect(h3, result.options.redirect); | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,60 @@ | ||
| import type { OIDCData, SessionWithToken, SessionSearchTerms } from "./types"; | ||
|
|
||
| function matchesOidc(session: SessionWithToken, oidc: OIDCData): boolean { | ||
| if (!session.oidc) { | ||
| return false; | ||
| } | ||
| for (const [key, value] of Object.entries(oidc)) { | ||
| if ( | ||
| JSON.stringify( | ||
| (session.oidc as unknown as Record<string, unknown>)[key], | ||
| ) !== JSON.stringify(value) | ||
| ) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| function matchesData( | ||
| session: SessionWithToken, | ||
| data: Record<string, unknown>, | ||
| ): boolean { | ||
| for (const [key, value] of Object.entries(data)) { | ||
| if (JSON.stringify(session.data[key]) !== JSON.stringify(value)) { | ||
| return false; | ||
| } | ||
| } | ||
| return true; | ||
| } | ||
|
|
||
| /** | ||
| * Checks if a session matches the given search criteria. | ||
| * | ||
| * @param session - The session to check | ||
| * @param options - The search criteria to match against | ||
| * @returns True if the session matches all criteria, false otherwise | ||
| */ | ||
| export function sessionMatchesFilter( | ||
| session: SessionWithToken, | ||
| options: SessionSearchTerms, | ||
| ): boolean { | ||
| if (options.userId) { | ||
| if (!session.authenticated?.userId) { | ||
| return false; | ||
| } | ||
| if (session.authenticated.userId !== options.userId) { | ||
| return false; | ||
| } | ||
| } | ||
|
|
||
| if (options.oidc && !matchesOidc(session, options.oidc)) { | ||
| return false; | ||
| } | ||
|
|
||
| if (options.data && !matchesData(session, options.data)) { | ||
| return false; | ||
| } | ||
|
|
||
| return true; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,5 +1,6 @@ | ||
| import type { H3Event } from "h3"; | ||
| import type { | ||
| OIDCData, | ||
| Session, | ||
| SessionSearchTerms, | ||
| SessionProvider, | ||
|
|
@@ -41,11 +42,11 @@ export interface SigninOptions { | |
| data?: Session["data"]; | ||
|
|
||
| // set oidc session data | ||
| oidc?: Session["oidc"]; | ||
| oidc?: OIDCData; | ||
| } | ||
|
|
||
| export class SessionHandler { | ||
| private sessionProvider: SessionProvider; | ||
| private readonly sessionProvider: SessionProvider; | ||
|
|
||
| constructor() { | ||
| // Create a new provider | ||
|
|
@@ -69,8 +70,13 @@ export class SessionHandler { | |
|
|
||
| const expiresAt = this.createExipreAt(rememberMe); | ||
|
|
||
| const token = | ||
| this.getSessionToken(h3) ?? this.createSessionCookie(h3, expiresAt); | ||
| // Invalidate any pre-existing session token — prevents session fixation | ||
| const oldToken = this.getSessionToken(h3); | ||
| const token = this.createSessionCookie(h3, expiresAt); | ||
| if (oldToken) { | ||
| await this.sessionProvider.removeSession(oldToken); | ||
| } | ||
|
|
||
| const defaultSession: Session = { | ||
| expiresAt, | ||
| data, | ||
|
|
@@ -108,7 +114,7 @@ export class SessionHandler { | |
| if (!token) | ||
| throw createError({ statusCode: 403, message: "User not signed in" }); | ||
| const session = await this.sessionProvider.getSession(token); | ||
| if (!session || !session.authenticated) | ||
| if (!session?.authenticated) | ||
| throw createError({ statusCode: 403, message: "User not signed in" }); | ||
|
|
||
| session.authenticated.level += amount; | ||
|
|
@@ -129,7 +135,7 @@ export class SessionHandler { | |
| // if expired session | ||
| if (new Date(session.expiresAt).getTime() < Date.now()) { | ||
| await this.sessionProvider.removeSession(token); | ||
| // TODO: should probably call signout to clear the cookie | ||
| // PENDING(sonar): call signout to clear cookie on expired session - deferred, needs safe cookie clearing path | ||
| // session expired | ||
| return undefined; | ||
| } | ||
|
|
@@ -186,7 +192,7 @@ export class SessionHandler { | |
| async signout(h3: H3Event) { | ||
| const token = this.getSessionToken(h3); | ||
| if (!token) return false; | ||
| if (!this.signoutByToken(token)) return false; | ||
| if (!(await this.signoutByToken(token))) return false; | ||
| deleteCookie(h3, dropTokenCookieName); | ||
| return true; | ||
| } | ||
|
|
@@ -264,9 +270,14 @@ export class SessionHandler { | |
| */ | ||
| private createSessionCookie(h3: H3Event, expiresAt: Date) { | ||
| const token = randomUUID(); | ||
| // TODO: we should probably switch to jwts to minimize possibility of someone | ||
| // trying to guess a session id (jwts let us sign + encrypt stuff in a std way) | ||
| setCookie(h3, dropTokenCookieName, token, { expires: expiresAt }); | ||
| // PENDING(sonar): consider switching to JWTs for session tokens - deferred, significant refactoring needed | ||
| setCookie(h3, dropTokenCookieName, token, { | ||
| expires: expiresAt, | ||
| httpOnly: true, | ||
| secure: getRequestURL(h3).protocol === "https:", | ||
| sameSite: "lax", | ||
| path: "/", | ||
|
Comment on lines
+274
to
+279
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚨 suggestion (security): Relying solely on getRequestURL(h3).protocol for the Because the flag is set from Suggested implementation: }
*/
// Determine whether the original client connection is HTTPS.
// This implementation considers both the direct protocol and a trusted "x-forwarded-proto" header,
// which is commonly set by reverse proxies / load balancers when terminating TLS.
private isSecureRequest(h3: H3Event): boolean {
const urlSecure = getRequestURL(h3).protocol === "https:";
const forwardedProtoHeader = h3.node.req.headers["x-forwarded-proto"];
const forwardedProto =
typeof forwardedProtoHeader === "string"
? forwardedProtoHeader.split(",")[0]?.trim().toLowerCase()
: undefined;
const proxySecure = forwardedProto === "https";
return urlSecure || proxySecure;
}
private createSessionCookie(h3: H3Event, expiresAt: Date) { setCookie(h3, dropTokenCookieName, token, {
expires: expiresAt,
httpOnly: true,
secure: this.isSecureRequest(h3),
sameSite: "lax",
path: "/",
}); |
||
| }); | ||
| return token; | ||
| } | ||
| } | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (bug_risk): Switching to USER node may require additional file permission adjustments in the image.
Running as
nodeis a solid hardening step, but it can fail at runtime if files or directories (like/app,startup/launch.sh, or writable cache/log paths) remain root-owned without appropriate permissions for thenodeuser. Please verify ownership/permissions across build and deployment environments and add explicitchown/chmodsteps where needed to ensure compatibility withUSER node.