diff --git a/apps/backend/.env-example b/apps/backend/.env-example index 1a3d724fa2..a91f936430 100644 --- a/apps/backend/.env-example +++ b/apps/backend/.env-example @@ -40,7 +40,12 @@ NGINX_HOST= -TENABLE_HOST_URL= +TENABLE_HOST_URL= # Authentication diff --git a/apps/backend/config/app_config.ts b/apps/backend/config/app_config.ts index 098a298e8d..958285a4a2 100644 --- a/apps/backend/config/app_config.ts +++ b/apps/backend/config/app_config.ts @@ -1,5 +1,6 @@ import * as dotenv from 'dotenv'; import * as fs from 'fs'; +import {parseHostUrl} from '../src/utils/url_validation'; export default class AppConfig { private envConfig: {[key: string]: string | undefined}; @@ -52,13 +53,26 @@ export default class AppConfig { } } - getTenableHostUrl(): string { + // Newline-separated allowlist of Tenable.SC hosts for the login/proxy endpoints + // Entries must include a protocol and may include a port; malformed entries throw at startup. + getTenableHostUrl(): string[] { const tenable_host_url = this.get('TENABLE_HOST_URL'); - if (tenable_host_url !== undefined) { - return tenable_host_url; - } else { - return ''; + if (tenable_host_url === undefined) { + return []; } + return tenable_host_url + .split('\n') + .map((url) => url.trim()) + .filter((url) => url.length > 0) + .map((url) => { + const parsed = parseHostUrl(url); + if (!parsed) { + throw new Error( + `Invalid TENABLE_HOST_URL entry "${url}": must be a complete http(s) URL containing only protocol, hostname, and optional port` + ); + } + return parsed.origin; + }); } getDatabaseName(): string { diff --git a/apps/backend/src/config/config.service.ts b/apps/backend/src/config/config.service.ts index b56c39f48a..397e4735a7 100644 --- a/apps/backend/src/config/config.service.ts +++ b/apps/backend/src/config/config.service.ts @@ -74,7 +74,7 @@ export class ConfigService { return this.appConfig.getSplunkHostUrl(); } - getTenableHostUrl(): string { + getTenableHostUrl(): string[] { return this.appConfig.getTenableHostUrl(); } diff --git a/apps/backend/src/config/dto/startup-settings.dto.ts b/apps/backend/src/config/dto/startup-settings.dto.ts index 3af275b85b..77f391e638 100644 --- a/apps/backend/src/config/dto/startup-settings.dto.ts +++ b/apps/backend/src/config/dto/startup-settings.dto.ts @@ -12,7 +12,7 @@ export class StartupSettingsDto implements IStartupSettings { readonly ldap: boolean; readonly registrationEnabled: boolean; readonly localLoginEnabled: boolean; - readonly tenableHostUrl: string; + readonly tenableHostUrl: string[]; readonly forceTenableFrontend: boolean; readonly splunkHostUrl: string; diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 54768d316b..4ae195fc95 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -58,7 +58,7 @@ async function bootstrap() { "'self'", 'https://api.github.com', 'https://sts.amazonaws.com', - configService.getTenableHostUrl(), + ...configService.getTenableHostUrl(), configService.getSplunkHostUrl() ].filter((source) => source) } diff --git a/apps/backend/src/tenable/tenable.controller.ts b/apps/backend/src/tenable/tenable.controller.ts index 153fd5df38..e9c70fb645 100644 --- a/apps/backend/src/tenable/tenable.controller.ts +++ b/apps/backend/src/tenable/tenable.controller.ts @@ -6,9 +6,14 @@ import { Body, HttpException, HttpStatus, - All + All, + UseGuards } from '@nestjs/common'; import {TenableService} from './tenable.service'; +import {JwtAuthGuard} from '../guards/jwt-auth.guard'; +import {ConfigService} from '../config/config.service'; +import {User} from '../users/user.model'; +import {parseHostUrl} from '../utils/url_validation'; import axios from 'axios'; import {Request, Response} from 'express'; @@ -19,6 +24,7 @@ declare module 'express-session' { host_url: string; accesskey: string; secretkey: string; + userId: string; }; } } @@ -30,8 +36,34 @@ const TENABLE_CSP_NOT_SET = // It allows users to log in with their Tenable credentials and then proxies all subsequent requests // to the Tenable API, handling authentication via session storage. @Controller('api/tenable') +@UseGuards(JwtAuthGuard) export class TenableController { - constructor(private readonly tenableService: TenableService) {} + constructor( + private readonly tenableService: TenableService, + private readonly configService: ConfigService + ) {} + + // Resolves host_url to its allowlisted origin, or null if not allowed + private resolveAllowedHostUrl(host_url: string): string | null { + const allowlist = this.configService.getTenableHostUrl(); + if (allowlist.length === 0) { + return null; + } + + const parsed = parseHostUrl(host_url); + if (!parsed) { + return null; + } + + // Restricting to the allowlist prevents Server-Side Request Forgery (SSRF), + // where an attacker supplies host_url to make this server call internal/ + // unintended hosts + const allowedEntry = allowlist.find((entry) => entry === parsed.origin); + + // Return the matched allowlist entry itself + // so the output is always exactly the admin-approved string + return allowedEntry ?? null; + } @Post('login') /** @@ -52,114 +84,139 @@ export class TenableController { throw new HttpException('Missing credentials', HttpStatus.BAD_REQUEST); } + const allowedHostUrl = this.resolveAllowedHostUrl(host_url); + if (!allowedHostUrl) { + // 400, not 403: the frontend treats any 403 here as a credentials error. + throw new HttpException( + { + status: HttpStatus.BAD_REQUEST, + message: 'Tenable host URL is not in the configured allowlist', + code: 'HOST_NOT_ALLOWED' + }, + HttpStatus.BAD_REQUEST + ); + } + try { - // This helps prevent double slashes in the resulting URL if host_url ends with a slash. - const fullUrl = `${host_url.replace(/\/$/, '')}/rest/currentUser`; + // allowedHostUrl is the parsed origin, so no trailing slash to strip. + const fullUrl = `${allowedHostUrl}/rest/currentUser`; const result = await axios.get(fullUrl, { headers: { 'x-apikey': `accesskey=${accesskey}; secretkey=${secretkey}` } }); - // Assign the Tenable credentials to the session - req.session.tenable = {host_url, accesskey, secretkey}; + // Store the normalized, allowlisted origin rather than the raw client value. + // Tagged with the authenticated user so a different user on the same + // browser session can't reuse these credentials (see proxy()). + req.session.tenable = { + host_url: allowedHostUrl, + accesskey, + secretkey, + userId: (req.user as User).id + }; // Return the authenticated user data // Note: result.data is already a plain object, no need to convert it. return {success: true, user: result.data}; // Return plain object } catch (err) { - if (axios.isAxiosError(err)) { - if (err.message.includes(TENABLE_CSP_NOT_SET)) { - throw new HttpException( - { - status: HttpStatus.NOT_FOUND, - message: 'Tenable CSP not set', - code: 'ERR_NETWORK' // custom application error code (optional) - }, - HttpStatus.NOT_FOUND - ); - } else if (err.response?.status === HttpStatus.UNAUTHORIZED) { - throw new HttpException( - { - status: HttpStatus.UNAUTHORIZED, - message: 'Invalid Tenable credentials', - code: 'INVALID_CREDENTIALS' // custom application error code (optional) - }, - HttpStatus.UNAUTHORIZED - ); - } else if (err.code === 'ECONNREFUSED') { - throw new HttpException( - { - status: HttpStatus.BAD_GATEWAY, - message: 'Tenable server is unreachable', - code: 'SERVER_UNREACHABLE' // custom app code - }, - HttpStatus.BAD_GATEWAY - ); - } else if (err.code === 'ENOTFOUND') { - throw new HttpException( - { - status: HttpStatus.BAD_REQUEST, - message: - 'Unable to resolve Tenable host URL to an IP address (possible DNS resolution on the hosting platform).', - code: 'INVALID_HOST_URL' // custom app code - }, - HttpStatus.BAD_REQUEST - ); - } else if (err.code === 'ETIMEDOUT') { - throw new HttpException( - { - status: HttpStatus.REQUEST_TIMEOUT, - message: 'Tenable server took too long to respond', - code: 'CONNECTION_TIMEOUT' // custom application error code (optional) - }, - HttpStatus.REQUEST_TIMEOUT - ); - } else if (err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { - throw new HttpException( - { - status: HttpStatus.BAD_GATEWAY, - message: - 'SSL certificate verification failed while connecting to Tenable ' + - `(${host_url}). This may be due to an untrusted or incomplete TLS ` + - 'certificate chain.', - code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' - }, - HttpStatus.BAD_GATEWAY - ); - } else { - throw new HttpException( - { - status: err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, - message: - err.response?.data?.message || - `Unexpected error connecting to Tenable ${host_url}`, - code: 'TENABLE_PROXY_ERROR' // Optional custom app code - }, - err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR - ); - } - } else if (err instanceof Error) { + this.handleLoginError(err, host_url); + } + } + + // Maps a login failure to the appropriate HttpException; always throws. + private handleLoginError(err: unknown, host_url: string): never { + if (axios.isAxiosError(err)) { + if (err.message.includes(TENABLE_CSP_NOT_SET)) { throw new HttpException( { - status: HttpStatus.INTERNAL_SERVER_ERROR, + status: HttpStatus.NOT_FOUND, + message: 'Tenable CSP not set', + code: 'ERR_NETWORK' // custom application error code (optional) + }, + HttpStatus.NOT_FOUND + ); + } else if (err.response?.status === HttpStatus.UNAUTHORIZED) { + throw new HttpException( + { + status: HttpStatus.UNAUTHORIZED, + message: 'Invalid Tenable credentials', + code: 'INVALID_CREDENTIALS' // custom application error code (optional) + }, + HttpStatus.UNAUTHORIZED + ); + } else if (err.code === 'ECONNREFUSED') { + throw new HttpException( + { + status: HttpStatus.BAD_GATEWAY, + message: 'Tenable server is unreachable', + code: 'SERVER_UNREACHABLE' // custom app code + }, + HttpStatus.BAD_GATEWAY + ); + } else if (err.code === 'ENOTFOUND') { + throw new HttpException( + { + status: HttpStatus.BAD_REQUEST, message: - err.message || - `Unexpected error connecting to Tenable ${host_url}`, - code: 'TENABLE_PROXY_ERROR' + 'Unable to resolve Tenable host URL to an IP address (possible DNS resolution on the hosting platform).', + code: 'INVALID_HOST_URL' // custom app code + }, + HttpStatus.BAD_REQUEST + ); + } else if (err.code === 'ETIMEDOUT') { + throw new HttpException( + { + status: HttpStatus.REQUEST_TIMEOUT, + message: 'Tenable server took too long to respond', + code: 'CONNECTION_TIMEOUT' // custom application error code (optional) }, - HttpStatus.INTERNAL_SERVER_ERROR + HttpStatus.REQUEST_TIMEOUT + ); + } else if (err.code === 'UNABLE_TO_VERIFY_LEAF_SIGNATURE') { + throw new HttpException( + { + status: HttpStatus.BAD_GATEWAY, + message: + 'SSL certificate verification failed while connecting to Tenable ' + + `(${host_url}). This may be due to an untrusted or incomplete TLS ` + + 'certificate chain.', + code: 'UNABLE_TO_VERIFY_LEAF_SIGNATURE' + }, + HttpStatus.BAD_GATEWAY ); } else { throw new HttpException( { - status: HttpStatus.INTERNAL_SERVER_ERROR, - message: `Unexpected error connecting to Tenable ${host_url}: ${JSON.stringify(err, null, 2)}`, - code: 'TENABLE_PROXY_ERROR' + status: err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR, + message: + err.response?.data?.message || + `Unexpected error connecting to Tenable ${host_url}`, + code: 'TENABLE_PROXY_ERROR' // Optional custom app code }, - HttpStatus.INTERNAL_SERVER_ERROR + err.response?.status || HttpStatus.INTERNAL_SERVER_ERROR ); } + } else if (err instanceof Error) { + throw new HttpException( + { + status: HttpStatus.INTERNAL_SERVER_ERROR, + message: + err.message || + `Unexpected error connecting to Tenable ${host_url}`, + code: 'TENABLE_PROXY_ERROR' + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); + } else { + throw new HttpException( + { + status: HttpStatus.INTERNAL_SERVER_ERROR, + message: `Unexpected error connecting to Tenable ${host_url}: ${JSON.stringify(err, null, 2)}`, + code: 'TENABLE_PROXY_ERROR' + }, + HttpStatus.INTERNAL_SERVER_ERROR + ); } } @@ -184,6 +241,12 @@ export class TenableController { return res.status(401).json({error: 'Not authenticated with Tenable'}); } + // Reject if these credentials belong to a different Heimdall user than + // the one currently authenticated (e.g. a shared/reused browser session). + if (creds.userId !== (req.user as User).id) { + return res.status(401).json({error: 'Not authenticated with Tenable'}); + } + // Forward the incoming request to the Tenable API using stored credentials. // Respond to the client with the status and data from Tenable's response or // handle any errors that occur during the proxy request. diff --git a/apps/backend/src/tenable/tenable.module.ts b/apps/backend/src/tenable/tenable.module.ts index 528a3d6d8d..48dbf2939b 100644 --- a/apps/backend/src/tenable/tenable.module.ts +++ b/apps/backend/src/tenable/tenable.module.ts @@ -1,11 +1,13 @@ import {Module} from '@nestjs/common'; import {TenableController} from './tenable.controller'; import {TenableService} from './tenable.service'; +import {ConfigModule} from '../config/config.module'; // NestJS module definition for the Tenable proxy feature. // Registers the controller and service needed for routing Tenable requests. @Module({ + imports: [ConfigModule], // Handles HTTP requests related to Tenable controllers: [TenableController], // Provides logic for proxying and interacting with Tenable API diff --git a/apps/backend/src/utils/url_validation.ts b/apps/backend/src/utils/url_validation.ts new file mode 100644 index 0000000000..5f592f7408 --- /dev/null +++ b/apps/backend/src/utils/url_validation.ts @@ -0,0 +1,28 @@ +// Parses `url` and returns it only if it is a bare http(s) origin (protocol + +// hostname + optional port, no userinfo/path/query/fragment). Returns null +// otherwise. Shared by AppConfig (admin-supplied TENABLE_HOST_URL) and +// TenableController (client-supplied host_url) +export function parseHostUrl(url: string): URL | null { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + return null; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + const hasExtra = + parsed.username !== '' || + parsed.password !== '' || + (parsed.pathname !== '' && parsed.pathname !== '/') || + parsed.search !== '' || + parsed.hash !== ''; + if (hasExtra) { + return null; + } + + return parsed; +} diff --git a/apps/frontend/src/components/global/upload_tabs/tenable/AuthStep.vue b/apps/frontend/src/components/global/upload_tabs/tenable/AuthStep.vue index 3233f5889b..ac532eab2f 100644 --- a/apps/frontend/src/components/global/upload_tabs/tenable/AuthStep.vue +++ b/apps/frontend/src/components/global/upload_tabs/tenable/AuthStep.vue @@ -61,9 +61,8 @@ import {requireFieldRule} from '@/utilities/upload_util'; import Vue from 'vue'; import Component from 'vue-class-component'; -// Our saved fields -const localAccesskey = new LocalStorageVal('tenable_accesskey'); -const localSecretkey = new LocalStorageVal('tenable_secretkey'); +// Only the hostname is persisted; access/secret keys are credentials and +// must not be saved to localStorage where any user of this browser could read them. const localHostname = new LocalStorageVal('tenable_hostname'); @Component({ @@ -120,8 +119,6 @@ export default class AuthStep extends Vue { await new TenableUtil(config) .loginToTenable() .then(() => { - localAccesskey.set(this.accesskey); - localSecretkey.set(this.secretkey); localHostname.set(this.hostname); SnackbarModule.notify('You have successfully signed in'); this.$emit('authenticated', config); @@ -136,11 +133,9 @@ export default class AuthStep extends Vue { /** Init our fields */ mounted() { - this.accesskey = localAccesskey.getDefault(''); - this.secretkey = localSecretkey.getDefault(''); - // If the hostname is not set, use the default from the server module + // If the hostname is not set, use the first configured default from the server module // (if not running in server mode the default is empty) - this.hostname = localHostname.getDefault(ServerModule.tenableHostUrl); + this.hostname = localHostname.getDefault(ServerModule.tenableHostUrl[0] ?? ''); } } diff --git a/apps/frontend/src/store/server.ts b/apps/frontend/src/store/server.ts index dc88d396a1..02e35e5622 100644 --- a/apps/frontend/src/store/server.ts +++ b/apps/frontend/src/store/server.ts @@ -36,7 +36,7 @@ export interface IServerState { ldap: boolean; localLoginEnabled: boolean; userInfo: IUser; - tenableHostUrl: string; + tenableHostUrl: string[]; forceTenableFrontend: boolean; splunkHostUrl: string; } @@ -68,7 +68,7 @@ class Server extends VuexModule implements IServerState { externalUrl: string = ''; allUsers: ISlimUser[] = []; oidcName = ''; - tenableHostUrl: string = ''; + tenableHostUrl: string[] = []; forceTenableFrontend = false; // If true, the frontend will use Tenable.SC Lite features splunkHostUrl: string = ''; /** Our currently granted JWT token */ diff --git a/apps/frontend/src/utilities/tenable_util.ts b/apps/frontend/src/utilities/tenable_util.ts index a6a55381be..a8618e6a45 100644 --- a/apps/frontend/src/utilities/tenable_util.ts +++ b/apps/frontend/src/utilities/tenable_util.ts @@ -159,6 +159,8 @@ export class TenableUtil { if (this.isServer) { if (error.response?.data?.code === 'INVALID_HOST_URL') { // Custom set in the backend rejectMsg = (error.response?.data?.message ?? 'Tenable host URL to IP address resolution failed'); + } else if (error.response?.data?.code === 'HOST_NOT_ALLOWED') { // Custom set in the backend + rejectMsg = (error.response?.data?.message ?? 'Tenable host URL is not in the configured allowlist'); } else if (error.response?.data?.message) { rejectMsg = this.getCSPErrorMsg(this.hostConfig.host_url, TENABLE_HOST_URL) } else { @@ -173,7 +175,7 @@ export class TenableUtil { 'Unauthorized (missing or not accepted credentials): ' + (error.response?.data?.message ?? error.message); } else if (error.status == 404) { - if (this.isServer && !TENABLE_HOST_URL) { + if (this.isServer && !TENABLE_HOST_URL.length) { rejectMsg = TENABLE_CSP_NOT_SET; } else { rejectMsg = `Network Error -> ${DEFAULT_REJECT_MSG}`; @@ -208,10 +210,10 @@ export class TenableUtil { if (error.code == 'ERR_NETWORK') { // Check if the tenable url was provided - Content Security Policy (CSP) const corsReject = `Possible access blocked by CORS or connection refused by the host: ${error.config.baseURL}. See Help for additional instructions. Received Error: ${error.message}`; - if (TENABLE_HOST_URL) { - // If the URL is listed in the allows domains + if (TENABLE_HOST_URL.length) { + // If the URL is listed in the allowed domains // (.env variable TENABLE_HOST_URL) check if they match - if (!error.config.baseURL.includes(TENABLE_HOST_URL)) { + if (!TENABLE_HOST_URL.some((url) => error.config.baseURL.includes(url))) { if (error.config.baseURL) { rejectMsg = this.getCSPErrorMsg(error.config.baseURL, TENABLE_HOST_URL) } else { @@ -375,11 +377,11 @@ export class TenableUtil { * Generates an error message indicating a Content Security Policy (CSP) violation. * * @param baseURL - The hostname that triggered the CSP violation. - * @param tenableUrl - The hostname allowed by the CSP. + * @param tenableUrls - The hostnames allowed by the CSP. * @returns A string describing the CSP violation, including the offending and allowed hostnames. */ - getCSPErrorMsg(baseURL: string, tenableUrl: string): string { - return `Hostname: ${baseURL?.trim() || 'Unknown host'} violates the Content Security Policy (CSP). The host allowed by the CSP is: ${tenableUrl?.trim() || 'Host not set'}`; + getCSPErrorMsg(baseURL: string, tenableUrls: string[]): string { + return `Hostname: ${baseURL?.trim() || 'Unknown host'} violates the Content Security Policy (CSP). The host(s) allowed by the CSP: ${tenableUrls.length ? tenableUrls.join(', ') : 'Host not set'}`; } } diff --git a/libs/common/interfaces/config/startup-settings.interface.ts b/libs/common/interfaces/config/startup-settings.interface.ts index f52b032bc3..6d60036c12 100644 --- a/libs/common/interfaces/config/startup-settings.interface.ts +++ b/libs/common/interfaces/config/startup-settings.interface.ts @@ -10,7 +10,7 @@ export interface IStartupSettings { readonly ldap: boolean; readonly registrationEnabled: boolean; readonly localLoginEnabled: boolean; - readonly tenableHostUrl: string; + readonly tenableHostUrl: string[]; readonly forceTenableFrontend: boolean; readonly splunkHostUrl: string; } diff --git a/problem-context.md b/problem-context.md new file mode 100644 index 0000000000..e69de29bb2