Skip to content
Merged
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: 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
9 changes: 0 additions & 9 deletions api/src/types/fastify.ts

This file was deleted.

4 changes: 2 additions & 2 deletions api/src/unraid-api/app/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ import { GraphQLError } from 'graphql';
import { getAllowedOrigins } from '@app/common/allowed-origins.js';
import { apiLogger } from '@app/core/log.js';
import { BYPASS_CORS_CHECKS } from '@app/environment.js';
import { FastifyRequest } from '@app/types/fastify.js';
import { type CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';

/**
* Returns whether the origin is allowed to access the API.
Expand Down 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
2 changes: 1 addition & 1 deletion api/src/unraid-api/auth/auth.guard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { IncomingMessage } from 'http';
import type { Observable } from 'rxjs';
import { parse as parseCookies } from 'cookie';

import type { FastifyRequest } from '@app/types/fastify.js';
import type { FastifyRequest } from '@app/unraid-api/types/fastify.js';
import { apiLogger } from '@app/core/log.js';
import { UserCookieStrategy } from '@app/unraid-api/auth/cookie.strategy.js';
import { ServerHeaderStrategy } from '@app/unraid-api/auth/header.strategy.js';
Expand Down
59 changes: 55 additions & 4 deletions api/src/unraid-api/auth/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ 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';
import { CookieService } from '@app/unraid-api/auth/cookie.service.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';

describe('AuthService', () => {
let authService: AuthService;
Expand Down Expand Up @@ -48,6 +49,15 @@ describe('AuthService', () => {
roles: [Role.GUEST, Role.CONNECT],
};

// Mock FastifyRequest object for tests
const createMockRequest = (overrides = {}) =>
({
headers: { 'x-csrf-token': undefined },
query: { csrf_token: undefined },
cookies: {},
...overrides,
}) as FastifyRequest;

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

Expand All @@ -66,36 +76,77 @@ 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({
headers: { 'x-csrf-token': 'valid-token' },
});
const result = await authService.validateCookiesWithCsrfToken(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({
headers: { 'x-csrf-token': 'valid-token' },
});
await expect(authService.validateCookiesWithCsrfToken(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.validateCookiesWithCsrfToken(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.validateCookiesWithCsrfToken(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({
headers: { 'x-csrf-token': 'invalid-token' },
});
await expect(authService.validateCookiesWithCsrfToken(mockRequest)).rejects.toThrow(
new UnauthorizedException('Invalid CSRF token')
);
});

it('should accept CSRF token from query parameter', async () => {
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 mockRequest = createMockRequest({
query: { csrf_token: 'valid-token' },
});
const result = await authService.validateCookiesWithCsrfToken(mockRequest);

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

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

@Injectable()
Expand Down Expand Up @@ -48,9 +49,13 @@ export class AuthService {
}
}

async validateCookiesCasbin(cookies: object): Promise<UserAccount> {
async validateCookiesWithCsrfToken(request: FastifyRequest): Promise<UserAccount> {
try {
if (!(await this.cookieService.hasValidAuthCookie(cookies))) {
if (!this.validateCsrfToken(request.headers['x-csrf-token'] || request.query.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 { AuthService } from '@app/unraid-api/auth/auth.service.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.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.validateCookiesWithCsrfToken(request);
};
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { Throttle } from '@nestjs/throttler';

import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';

Expand All @@ -13,12 +11,9 @@ import type {
} from '@app/graphql/generated/api/types.js';
import { Resource, Role } from '@app/graphql/generated/api/types.js';
import { ApiKeyService } from '@app/unraid-api/auth/api-key.service.js';
import { GraphqlAuthGuard } from '@app/unraid-api/auth/auth.guard.js';
import { AuthService } from '@app/unraid-api/auth/auth.service.js';

@Resolver('ApiKey')
@UseGuards(GraphqlAuthGuard)
@Throttle({ default: { limit: 100, ttl: 60000 } }) // 100 requests per minute
export class ApiKeyResolver {
constructor(
private authService: AuthService,
Expand Down
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
2 changes: 1 addition & 1 deletion api/src/unraid-api/rest/rest.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { Controller, Get, Logger, Param, Res } from '@nestjs/common';

import { AuthActionVerb, AuthPossession, UsePermissions } from 'nest-authz';

import type { FastifyReply } from '@app/types/fastify.js';
import type { FastifyReply } from '@app/unraid-api/types/fastify.js';
import { Resource } from '@app/graphql/generated/api/types.js';
import { Public } from '@app/unraid-api/auth/public.decorator.js';
import { RestService } from '@app/unraid-api/rest/rest.service.js';
Expand Down
33 changes: 33 additions & 0 deletions api/src/unraid-api/types/fastify.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type {
FastifyInstance as BaseFastifyInstance,
FastifyReply as BaseFastifyReply,
FastifyRequest as BaseFastifyRequest,
} from 'fastify';

// Common headers
export interface CommonHeaders {
'x-api-key'?: string;
'x-csrf-token'?: string;
'x-unraid-api-version'?: string;
'x-flash-guid'?: string;
}

// Common query parameters
export interface CommonQuery {
csrf_token?: string;
}

// Base types
type Headers = BaseFastifyRequest['headers'] & Partial<CommonHeaders>;
type Query = BaseFastifyRequest['query'] & Partial<CommonQuery>;
type Cookies = BaseFastifyRequest['cookies'];

export type FastifyRequest = BaseFastifyRequest<{
Headers: Headers;
Querystring: Query;
}> & {
cookies?: Cookies;
};

export type FastifyInstance = BaseFastifyInstance;
export type FastifyReply = BaseFastifyReply;
5 changes: 0 additions & 5 deletions api/src/unraid-api/types/request.ts

This file was deleted.

2 changes: 1 addition & 1 deletion api/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { dirname } from 'node:path';
import strftime from 'strftime';

import { UserAccount } from '@app/graphql/generated/api/types.js';
import { FastifyRequest } from '@app/types/fastify.js';
import { FastifyRequest } from '@app/unraid-api/types/fastify.js';

export function notNull<T>(value: T): value is NonNullable<T> {
return value !== null;
Expand Down
Loading
Loading