feat(core): Add SAML XML validation (#5600)

* consolidate SSO settings

* update saml settings

* fix type error

* limit user changes when saml is enabled

* add test

* add toggle endpoint and fetch metadata

* rename enabled param

* add handling of POST saml login request

* add config test endpoint

* adds saml XML validation

* add comment

* protect test endpoint

* improve ignoreSSL and some cleanup

* fix wrong schema used

* remove console.log
This commit is contained in:
Michael Auerswald
2023-03-06 09:44:25 +01:00
committed by GitHub
parent ddfa16cf27
commit ca66ec8f4d
16 changed files with 1672 additions and 51 deletions

View File

@@ -10,7 +10,7 @@ import { isSsoJustInTimeProvisioningEnabled } from '../ssoHelpers';
import type { SamlPreferences } from './types/samlPreferences';
import { SAML_PREFERENCES_DB_KEY } from './constants';
import type { IdentityProviderInstance } from 'samlify';
import { IdentityProvider } from 'samlify';
import { IdentityProvider, setSchemaValidator } from 'samlify';
import {
createUserFromSamlAttributes,
getMappedSamlAttributesFromFlowResult,
@@ -22,8 +22,10 @@ import {
} from './samlHelpers';
import type { Settings } from '../../databases/entities/Settings';
import axios from 'axios';
import https from 'https';
import type { SamlLoginBinding } from './types';
import type { BindingContext, PostBindingContext } from 'samlify/types/src/entity';
import { validateMetadata, validateResponse } from './samlValidator';
export class SamlService {
private static instance: SamlService;
@@ -46,30 +48,14 @@ export class SamlService {
this._attributeMapping = mapping;
}
private _metadata = '';
private metadata = '';
private metadataUrl = '';
private ignoreSSL = false;
private loginBinding: SamlLoginBinding = 'post';
public get metadata(): string {
return this._metadata;
}
public set metadata(metadata: string) {
this._metadata = metadata;
}
constructor() {
this.loadFromDbAndApplySamlPreferences()
.then(() => {
LoggerProxy.debug('Initializing SAML service');
})
.catch(() => {
LoggerProxy.error('Error initializing SAML service');
});
}
static getInstance(): SamlService {
if (!SamlService.instance) {
SamlService.instance = new SamlService();
@@ -79,6 +65,15 @@ export class SamlService {
async init(): Promise<void> {
await this.loadFromDbAndApplySamlPreferences();
setSchemaValidator({
validate: async (response: string) => {
const valid = await validateResponse(response);
if (!valid) {
return Promise.reject(new Error('Invalid SAML response'));
}
return Promise.resolve();
},
});
}
getIdentityProviderInstance(forceRecreate = false): IdentityProviderInstance {
@@ -125,7 +120,6 @@ export class SamlService {
'post',
) as PostBindingContext;
//TODO:SAML: debug logging
LoggerProxy.debug(loginRequest.context);
return loginRequest;
}
@@ -188,6 +182,7 @@ export class SamlService {
mapping: this.attributeMapping,
metadata: this.metadata,
metadataUrl: this.metadataUrl,
ignoreSSL: this.ignoreSSL,
loginBinding: this.loginBinding,
loginEnabled: isSamlLoginEnabled(),
loginLabel: getSamlLoginLabel(),
@@ -198,12 +193,19 @@ export class SamlService {
this.loginBinding = prefs.loginBinding ?? this.loginBinding;
this.metadata = prefs.metadata ?? this.metadata;
this.attributeMapping = prefs.mapping ?? this.attributeMapping;
this.ignoreSSL = prefs.ignoreSSL ?? this.ignoreSSL;
if (prefs.metadataUrl) {
this.metadataUrl = prefs.metadataUrl;
const fetchedMetadata = await this.fetchMetadataFromUrl();
if (fetchedMetadata) {
this.metadata = fetchedMetadata;
}
} else if (prefs.metadata) {
const validationResult = await validateMetadata(prefs.metadata);
if (!validationResult) {
throw new Error('Invalid SAML metadata');
}
this.metadata = prefs.metadata;
}
setSamlLoginEnabled(prefs.loginEnabled ?? isSamlLoginEnabled());
setSamlLoginLabel(prefs.loginLabel ?? getSamlLoginLabel());
@@ -248,18 +250,24 @@ export class SamlService {
async fetchMetadataFromUrl(): Promise<string | undefined> {
try {
const prevRejectStatus = process.env.NODE_TLS_REJECT_UNAUTHORIZED;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
const response = await axios.get(this.metadataUrl);
process.env.NODE_TLS_REJECT_UNAUTHORIZED = prevRejectStatus;
// TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity)
const agent = new https.Agent({
rejectUnauthorized: !this.ignoreSSL,
});
const response = await axios.get(this.metadataUrl, { httpsAgent: agent });
if (response.status === 200 && response.data) {
const xml = (await response.data) as string;
// TODO: SAML: validate XML
// throw new BadRequestError('Received XML is not valid SAML metadata.');
const validationResult = await validateMetadata(xml);
if (!validationResult) {
throw new BadRequestError(
`Data received from ${this.metadataUrl} is not valid SAML metadata.`,
);
}
return xml;
}
} catch (error) {
throw new BadRequestError('SAML Metadata URL is invalid or response is .');
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
throw new BadRequestError(`Error fetching SAML Metadata from ${this.metadataUrl}: ${error}`);
}
return;
}
@@ -298,10 +306,14 @@ export class SamlService {
async testSamlConnection(): Promise<boolean> {
try {
// TODO:SAML: this will not work once axios is upgraded to > 1.2.0 (see checkServerIdentity)
const agent = new https.Agent({
rejectUnauthorized: !this.ignoreSSL,
});
const requestContext = this.getLoginRequestUrl();
if (!requestContext) return false;
if (requestContext.binding === 'redirect') {
const fetchResult = await axios.get(requestContext.context.context);
const fetchResult = await axios.get(requestContext.context.context, { httpsAgent: agent });
if (fetchResult.status !== 200) {
LoggerProxy.debug('SAML: Error while testing SAML connection.');
return false;
@@ -319,6 +331,7 @@ export class SamlService {
// eslint-disable-next-line @typescript-eslint/naming-convention
'Content-type': 'application/x-www-form-urlencoded',
},
httpsAgent: agent,
});
if (fetchResult.status !== 200) {
LoggerProxy.debug('SAML: Error while testing SAML connection.');