Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -102,4 +102,7 @@ ENV NGINX_CONFIG="/nginx.conf"
# Nuxt's port
ENV PORT=4000

# Run as non-root user for security
USER node
Comment on lines +105 to +106

Copy link
Copy Markdown

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 node is 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 the node user. Please verify ownership/permissions across build and deployment environments and add explicit chown/chmod steps where needed to ensure compatibility with USER node.


CMD ["sh", "/app/startup/launch.sh"]
29 changes: 22 additions & 7 deletions server/nuxt.config.ts
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";
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 process.env.PATH when present. While the behavior is fine, the comment overstates the safety guarantees. Consider rephrasing to emphasize that execFileSync avoids a shell and that a safe fallback PATH is used only when process.env.PATH is unset, rather than implying additional sanitization.


console.log(`Drop ${dropVersion} #${commitHash}`);

Expand Down Expand Up @@ -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]),
],
},

Expand Down Expand Up @@ -262,11 +273,15 @@ export default defineNuxtConfig({
"https://*.steamstatic.com",
],
},
strictTransportSecurity: false,
strictTransportSecurity: { maxAge: 31536000, includeSubdomains: true },
},
Comment on lines 275 to 277

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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. NODE_ENV === "production") or making it configurable so HSTS is only enabled on intended public domains.

Suggested change
},
strictTransportSecurity: false,
strictTransportSecurity: { maxAge: 31536000, includeSubdomains: true },
},
},
strictTransportSecurity:
process.env.NODE_ENV === "production"
? { maxAge: 31536000, includeSubdomains: true }
: false,
},

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,
},
},
});

Expand Down
16 changes: 16 additions & 0 deletions server/server/api/v1/auth/oidc/callback.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The 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 redirectUrl object but then pass the original string to sendRedirect, you risk discrepancies between what was validated and what is sent. Please use redirectUrl.toString() (or .href) in the redirect so the exact validated (and normalized) URL is used.

if (redirectUrl.origin !== requestOrigin) {
throw createError({
statusCode: 400,
message: "Invalid redirect URL",
});
}
return sendRedirect(h3, result.options.redirect);
}

Expand Down
4 changes: 2 additions & 2 deletions server/server/api/v1/auth/signin/simple.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { logger } from "~/server/internal/logging";

const signinValidator = type({
username: "string",
password: "string",
password: "string<=128",
"rememberMe?": "boolean | undefined",
});

Expand Down Expand Up @@ -83,7 +83,7 @@ export default defineEventHandler<{
message: t("errors.auth.invalidUserOrPass"),
});

// TODO: send user to forgot password screen or something to force them to change their password to new system
// PENDING(sonar): redirect user to password change flow when password hash needs migration - deferred
const result = await sessionHandler.signin(h3, authMek.userId, {
rememberMe: body.rememberMe ?? false,
});
Expand Down
2 changes: 1 addition & 1 deletion server/server/api/v1/auth/signup/simple.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export const SharedRegisterValidator = type({

const CreateUserValidator = SharedRegisterValidator.and({
invitation: "string",
password: "string >= 8",
password: "string >= 8 & string <= 128",
"displayName?": "string | undefined",
}).configure(throwingArktype);

Expand Down
40 changes: 7 additions & 33 deletions server/server/internal/session/cache.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import cacheHandler from "../cache";
import type { SessionProvider, SessionWithToken } from "./types";
import { sessionMatchesFilter } from "./filter";

/**
* DO NOT USE THIS. THE CACHE EVICTS SESSIONS.
* Creates a cache-backed session provider for in-memory session management.
*
* This needs work. TODO.
* Sessions may be evicted by the cache, so this provider is unsuitable for
* reliable session persistence.
*
* @returns A cache-backed session provider
*/
export default function createCacheSessionProvider() {
const sessions = cacheHandler.createCache<SessionWithToken>(
Expand Down Expand Up @@ -49,37 +53,7 @@ export default function createCacheSessionProvider() {
for (const token of await sessions.getKeys()) {
const session = await sessions.get(token);
if (!session) continue;
let match = true;

if (
options.userId &&
session.authenticated &&
session.authenticated.userId !== options.userId
) {
match = false;
}
if (options.oidc && session.oidc) {
for (const [key, value] of Object.entries(options.oidc)) {
// stringify to do deep comparison
if (
JSON.stringify(
(session.oidc as unknown as Record<string, unknown>)[key],
) !== JSON.stringify(value)
) {
match = false;
break;
}
}
}

for (const [key, value] of Object.entries(options.data || {})) {
// stringify to do deep comparison
if (JSON.stringify(session.data[key]) !== JSON.stringify(value)) {
match = false;
break;
}
}
if (match) {
if (sessionMatchesFilter(session, options)) {
results.push(session);
}
}
Expand Down
62 changes: 40 additions & 22 deletions server/server/internal/session/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,35 +157,53 @@ function walkJsonPath(
obj: unknown,
basePath: string[] = [],
): Array<{ path: string[]; value: unknown }> {
const results: Array<{ path: string[]; value: unknown }> = [];

if (Array.isArray(obj)) {
for (let i = 0; i < obj.length; i++) {
const v = obj[i];
if (v === undefined) continue;
if (v !== null && typeof v === "object") {
results.push(...walkJsonPath(v, [...basePath, String(i)]));
} else {
results.push({ path: [...basePath, String(i)], value: v });
}
}
return results;
return walkArray(obj, basePath);
}

if (obj !== null && typeof obj === "object") {
for (const [k, v] of Object.entries(obj as Record<string, unknown>)) {
if (v === undefined) continue;
if (v !== null && typeof v === "object") {
results.push(...walkJsonPath(v, [...basePath, k]));
} else {
results.push({ path: [...basePath, k], value: v });
}
}
return results;
return walkObject(obj as Record<string, unknown>, basePath);
}

if (basePath.length > 0) {
results.push({ path: basePath, value: obj });
return [{ path: basePath, value: obj }];
}
return [];
}

function walkArray(
arr: unknown[],
basePath: string[],
): Array<{ path: string[]; value: unknown }> {
const results: Array<{ path: string[]; value: unknown }> = [];
for (let i = 0; i < arr.length; i++) {
const v = arr[i];
if (v === undefined) continue;
collectPathValue(v, [...basePath, String(i)], results);
}
return results;
}

function walkObject(
obj: Record<string, unknown>,
basePath: string[],
): Array<{ path: string[]; value: unknown }> {
const results: Array<{ path: string[]; value: unknown }> = [];
for (const [k, v] of Object.entries(obj)) {
if (v === undefined) continue;
collectPathValue(v, [...basePath, k], results);
}
return results;
}

function collectPathValue(
value: unknown,
path: string[],
results: Array<{ path: string[]; value: unknown }>,
) {
if (value !== null && typeof value === "object") {
results.push(...walkJsonPath(value, path));
} else {
results.push({ path, value });
}
}
60 changes: 60 additions & 0 deletions server/server/internal/session/filter.ts
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;
}
31 changes: 21 additions & 10 deletions server/server/internal/session/index.ts
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,
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 suggestion (security): Relying solely on getRequestURL(h3).protocol for the secure flag may be inaccurate behind proxies.

Because the flag is set from getRequestURL(h3).protocol === "https:", a proxied setup with TLS terminated at a load balancer may still show http: to the app, resulting in cookies without secure even when the client uses HTTPS. If this handler can run behind such infrastructure, consider also checking a trusted indicator of HTTPS (e.g. X-Forwarded-Proto, a trusted proxy setting, or a config/env flag) when deciding whether to set secure.

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;
}
}
Expand Down
Loading
Loading