Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 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: 2 additions & 1 deletion api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@apollo/server": "^4.11.2",
"@as-integrations/fastify": "^2.1.1",
"@fastify/cookie": "^9.4.0",
"@fastify/helmet": "^13.0.1",
"@graphql-codegen/client-preset": "^4.5.0",
"@graphql-tools/load-files": "^7.0.0",
"@graphql-tools/merge": "^9.0.8",
Expand Down Expand Up @@ -174,8 +175,8 @@
"cz-conventional-changelog": "3.3.0",
"eslint": "^9.20.1",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-no-relative-import-paths": "^1.6.1",
"eslint-plugin-n": "^17.0.0",
"eslint-plugin-no-relative-import-paths": "^1.6.1",
"eslint-plugin-prettier": "^5.2.3",
"graphql-codegen-typescript-validation-schema": "^0.17.0",
"jiti": "^2.4.0",
Expand Down
5 changes: 4 additions & 1 deletion api/src/types/fastify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,8 @@ import type {
} from 'fastify';

export type FastifyInstance = BaseFastifyInstance;
export type FastifyRequest = BaseFastifyRequest;
export interface FastifyRequest extends BaseFastifyRequest {
cookies: Record<string, string | undefined>;
headers: Record<string, string | undefined>;
}
export type FastifyReply = BaseFastifyReply;
2 changes: 1 addition & 1 deletion api/src/unraid-api/app/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export const configureFastifyCors =
*/
(req: FastifyRequest, callback: (error: Error | null, options: CorsOptions) => void) => {
const { cookies } = req;
if (typeof cookies === 'object') {
if (cookies && typeof cookies === 'object') {
service.hasValidAuthCookie(cookies).then((isValid) => {
if (isValid) {
callback(null, { credentials: true, origin: true });
Expand Down
43 changes: 39 additions & 4 deletions api/src/unraid-api/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthZService } from 'nest-authz';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import type { ApiKey, ApiKeyWithSecret, UserAccount } from '@app/graphql/generated/api/types.js';
import type { FastifyRequest } from '@app/types/fastify.js';
import { Resource, Role } from '@app/graphql/generated/api/types.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';
Expand Down Expand Up @@ -48,6 +49,19 @@ describe('AuthService', () => {
roles: [Role.GUEST, Role.CONNECT],
};

// Mock FastifyRequest object for tests
const createMockRequest = (overrides = {}): FastifyRequest => {
return {
headers: {},
query: {},
cookies: {},
id: 'test-id',
params: {},
raw: {} as any,
...overrides,
} as FastifyRequest;
};

beforeEach(async () => {
const enforcer = await newEnforcer();

Expand All @@ -66,36 +80,57 @@ describe('AuthService', () => {
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(mockUser);
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([Role.ADMIN]);
vi.spyOn(authService, 'validateCsrfToken').mockReturnValue(true);

const result = await authService.validateCookiesCasbin({});
const mockRequest = createMockRequest();
const result = await authService.validateCookiesCasbin(mockRequest);

expect(result).toEqual(mockUser);
});

it('should throw UnauthorizedException when auth cookie is invalid', async () => {
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(false);
vi.spyOn(authService, 'validateCsrfToken').mockReturnValue(true);

await expect(authService.validateCookiesCasbin({})).rejects.toThrow(UnauthorizedException);
const mockRequest = createMockRequest();
await expect(authService.validateCookiesCasbin(mockRequest)).rejects.toThrow(
UnauthorizedException
);
});

it('should throw UnauthorizedException when session user is missing', async () => {
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(null as unknown as UserAccount);
vi.spyOn(authService, 'validateCsrfToken').mockReturnValue(true);

await expect(authService.validateCookiesCasbin({})).rejects.toThrow(UnauthorizedException);
const mockRequest = createMockRequest();
await expect(authService.validateCookiesCasbin(mockRequest)).rejects.toThrow(
UnauthorizedException
);
});

it('should add guest role when user has no roles', async () => {
vi.spyOn(cookieService, 'hasValidAuthCookie').mockResolvedValue(true);
vi.spyOn(authService, 'getSessionUser').mockResolvedValue(mockUser);
vi.spyOn(authzService, 'getRolesForUser').mockResolvedValue([]);
vi.spyOn(authService, 'validateCsrfToken').mockReturnValue(true);

const addRoleSpy = vi.spyOn(authzService, 'addRoleForUser');
const result = await authService.validateCookiesCasbin({});
const mockRequest = createMockRequest();
const result = await authService.validateCookiesCasbin(mockRequest);

expect(result).toEqual(mockUser);
expect(addRoleSpy).toHaveBeenCalledWith(mockUser.id, 'guest');
});

it('should throw UnauthorizedException when CSRF token is invalid', async () => {
vi.spyOn(authService, 'validateCsrfToken').mockReturnValue(false);

const mockRequest = createMockRequest();
await expect(authService.validateCookiesCasbin(mockRequest)).rejects.toThrow(
new UnauthorizedException('Invalid CSRF token')
);
});
});

describe('syncApiKeyRoles', () => {
Expand Down
14 changes: 12 additions & 2 deletions api/src/unraid-api/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { AuthZService } from 'nest-authz';
import type { Permission, UserAccount } from '@app/graphql/generated/api/types.js';
import { Role } from '@app/graphql/generated/api/types.js';
import { getters } from '@app/store/index.js';
import { FastifyRequest } from '@app/types/fastify.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { batchProcess, handleAuthError } from '@app/utils.js';
Expand Down Expand Up @@ -48,9 +49,18 @@ export class AuthService {
}
}

async validateCookiesCasbin(cookies: object): Promise<UserAccount> {
async validateCookiesCasbin(request: FastifyRequest): Promise<UserAccount> {
try {
if (!(await this.cookieService.hasValidAuthCookie(cookies))) {
if (
!this.validateCsrfToken(
request.headers['x-csrf-token'] ||
(request.query as { csrf_token?: string })?.csrf_token
)
) {
throw new UnauthorizedException('Invalid CSRF token');
}

if (!(await this.cookieService.hasValidAuthCookie(request.cookies))) {
throw new UnauthorizedException('No user session found');
}

Expand Down
4 changes: 2 additions & 2 deletions api/src/unraid-api/auth/cookie.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,9 +43,9 @@ export class CookieService {
* @param opts optional overrides for the session directory & prefix of the session cookie to look for
* @returns true if any of the cookies are a valid unraid session cookie, false otherwise
*/
async hasValidAuthCookie(cookies: object): Promise<boolean> {
async hasValidAuthCookie(cookies: Record<string, string | undefined>): Promise<boolean> {
const { data } = await batchProcess(Object.entries(cookies), ([cookieName, cookieValue]) =>
this.isValidAuthCookie(String(cookieName), String(cookieValue))
this.isValidAuthCookie(String(cookieName), String(cookieValue ?? ''))
);
return data.some((valid) => valid);
}
Expand Down
13 changes: 4 additions & 9 deletions api/src/unraid-api/auth/cookie.strategy.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,22 @@
import { Injectable, Logger } from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';

import { Strategy } from 'passport-custom';

import type { CustomRequest } from '@app/unraid-api/types/request.js';
import { FastifyRequest } from '@app/types/fastify.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';

const strategyName = 'user-cookie';

@Injectable()
export class UserCookieStrategy extends PassportStrategy(Strategy, strategyName) {
static key = strategyName;
private readonly logger = new Logger(UserCookieStrategy.name);

constructor(private authService: AuthService) {
super();
}

public validate = async (req: CustomRequest): Promise<any> => {
return (
this.authService.validateCsrfToken(
req.headers['x-csrf-token'] || (req.query as { csrf_token?: string })?.csrf_token
) && this.authService.validateCookiesCasbin(req.cookies)
);
public validate = async (request: FastifyRequest): Promise<any> => {
return this.authService.validateCookiesCasbin(request);
};
}
39 changes: 33 additions & 6 deletions api/src/unraid-api/main.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
import type { NestFastifyApplication } from '@nestjs/platform-fastify';
import { NestFactory } from '@nestjs/core';
import { FastifyAdapter } from '@nestjs/platform-fastify';
import { FastifyAdapter } from '@nestjs/platform-fastify/adapters';

import fastifyCookie from '@fastify/cookie';
import fastifyHelmet from '@fastify/helmet';
import { LoggerErrorInterceptor, Logger as PinoLogger } from 'nestjs-pino';

import { apiLogger } from '@app/core/log.js';
import { LOG_LEVEL, PORT } from '@app/environment.js';
import { AppModule } from '@app/unraid-api/app/app.module.js';
import { configureFastifyCors } from '@app/unraid-api/app/cors.js';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { GraphQLExceptionsFilter } from '@app/unraid-api/exceptions/graphql-exceptions.filter.js';
import { HttpExceptionFilter } from '@app/unraid-api/exceptions/http-exceptions.filter.js';

Expand All @@ -23,10 +22,38 @@ export async function bootstrapNestServer(): Promise<NestFastifyApplication> {

const server = app.getHttpAdapter().getInstance();

await app.register(fastifyCookie); // parse cookies before cors
await server.register(fastifyCookie);

const cookieService = app.get(CookieService);
app.enableCors(configureFastifyCors(cookieService));
// Minimal Helmet configuration to avoid blocking plugin functionality
await server.register(fastifyHelmet, {
// Disable restrictive policies
contentSecurityPolicy: false,
crossOriginEmbedderPolicy: false,
crossOriginOpenerPolicy: false,
crossOriginResourcePolicy: false,

// Basic security headers that don't restrict functionality
xssFilter: true,
hidePoweredBy: true,

// Additional safe headers
noSniff: true, // Prevents MIME type sniffing
ieNoOpen: true, // Prevents IE from executing downloads in site context
permittedCrossDomainPolicies: true, // Restricts Adobe Flash and PDF access
referrerPolicy: { policy: 'no-referrer-when-downgrade' }, // Safe referrer policy
frameguard: false, // Turn off for plugin compatibility

// HSTS disabled to avoid issues with running on local networks
hsts: false,
});

// Allows all origins but still checks authentication
app.enableCors({
origin: true, // Allows all origins
credentials: true,
methods: ['GET', 'PUT', 'POST', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With'],
});

// Setup Nestjs Pino Logger
app.useLogger(app.get(PinoLogger));
Expand Down
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.