dbeaver/pro#6462 error instead of redirect for gql requests (#3690)

* dbeaver/pro#6462 error instead of redirect for gql requests

* feat: force redirect on supportedHosts change

* fix: replace redirect with warning message

* fix: prevent active host removal

* fix: error message and ignore when on IP address

---------

Co-authored-by: Alexey Potsetsuev <wrouds@gmail.com>
Co-authored-by: kseniaguzeeva <112612526+kseniaguzeeva@users.noreply.github.com>
This commit is contained in:
Alexander Skoblikov
2025-08-26 15:01:25 +02:00
committed by GitHub
co-authored by Alexey Potsetsuev kseniaguzeeva
parent 564ceb7d82
commit 3877e914c4
18 changed files with 165 additions and 32 deletions
@@ -120,12 +120,13 @@ public class CBJettyServer {
GraphQLEndpoint endpoint = new GraphQLEndpoint(new ServerConfigurationTimeLimitFilter(application));
application.addApplicationContextValue(GraphQL.class.getName(), endpoint.getGraphQL());
String gqlServletPath = serverConfiguration.getServicesURI() + "gql/*";
servletContextHandler.addServlet(
new ServletHolder(
"graphql",
endpoint
),
serverConfiguration.getServicesURI() + "gql/*"
gqlServletPath
);
servletContextHandler.addEventListener(new CBServerContextListener(application));
@@ -157,7 +158,11 @@ public class CBJettyServer {
}
}
}
FilterHolder hostsFilter = new FilterHolder(new RequestHostFilter(application, excludedFilterPaths));
FilterHolder hostsFilter = new FilterHolder(new RequestHostFilter(
application,
excludedFilterPaths,
Set.of(gqlServletPath)
));
servletContextHandler.addFilter(hostsFilter, "/*", null);
@@ -39,18 +39,30 @@ public class RequestHostFilter implements Filter {
@NotNull
private final CBApplication<?> application;
private final Set<String> excludedPaths = new HashSet<>();
private final Set<String> errorPaths = new HashSet<>();
public RequestHostFilter(@NotNull CBApplication<?> application, @NotNull Set<String> excludedPaths) {
public RequestHostFilter(
@NotNull CBApplication<?> application,
@NotNull Set<String> excludedPaths,
@NotNull Set<String> errorPaths
) {
this.application = application;
this.excludedPaths.addAll(
excludedPaths.stream()
.map(path -> ServletAppUtils.removeSideSlashes(path.replace("*", "")))
.toList()
);
this.errorPaths.addAll(
errorPaths.stream()
.map(path -> ServletAppUtils.removeSideSlashes(path.replace("*", "")))
.toList()
);
}
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
boolean requestAllowed = true;
if (request instanceof HttpServletRequest httpRequest) {
CBServerConfig serverConfig = application.getServerConfiguration();
URI originUri;
@@ -64,7 +76,8 @@ public class RequestHostFilter implements Filter {
}
boolean isIpAddress = InetAddresses.isInetAddress(originUri.getHost());
String servletPath = httpRequest.getServletPath();
if (!isIpAddress) {
requestAllowed = isIpAddress;
if (!requestAllowed) {
if (CommonUtils.isNotEmpty(servletPath)) {
for (String excludedPath : excludedPaths) {
if (servletPath.contains(excludedPath)) {
@@ -73,17 +86,21 @@ public class RequestHostFilter implements Filter {
}
}
}
validateHosts(serverConfig, httpRequest, response, originUri);
requestAllowed = validateHosts(serverConfig, httpRequest, (HttpServletResponse) response, originUri);
}
if (requestAllowed) {
requestAllowed = validateSchema(serverConfig, httpRequest, (HttpServletResponse) response, originUri);
}
validateSchema(serverConfig, httpRequest, response, originUri);
}
chain.doFilter(request, response);
if (requestAllowed) {
chain.doFilter(request, response);
}
}
private void validateSchema(
private boolean validateSchema(
@NotNull CBServerConfig serverConfig,
@NotNull HttpServletRequest httpRequest,
@NotNull ServletResponse response,
@NotNull HttpServletResponse response,
@NotNull URI originUri
) {
boolean httpsExpected = serverConfig.isForceHttps();
@@ -100,22 +117,24 @@ public class RequestHostFilter implements Filter {
redirectUrlBuilder.append("?")
.append(httpRequest.getQueryString());
}
((HttpServletResponse) response).sendRedirect(redirectUrlBuilder.toString());
response.sendRedirect(redirectUrlBuilder.toString());
return false;
}
} catch (Exception e) {
log.error("Failed to redirect to HTTPS", e);
}
return true;
}
private void validateHosts(
private boolean validateHosts(
@NotNull CBServerConfig serverConfig,
@NotNull HttpServletRequest httpRequest,
@NotNull ServletResponse response,
@NotNull HttpServletResponse response,
URI originUri
) throws IOException {
List<String> availableHosts = serverConfig.getSupportedHosts();
if (CommonUtils.isEmpty(availableHosts)) {
return;
return true;
}
try {
var requestHostBuilder = new StringBuilder(originUri.getHost());
@@ -124,13 +143,25 @@ public class RequestHostFilter implements Filter {
}
String requestHost = requestHostBuilder.toString();
if (!availableHosts.contains(requestHost)) {
for (String errorPath : errorPaths) {
if (httpRequest.getServletPath().contains(errorPath)) {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.getWriter().write(
"Request host '" + requestHost + "' is not allowed. Available hosts: " + availableHosts
);
return false;
}
}
log.warn("Request host '" + requestHost + "' is not allowed. Redirect to default: " + availableHosts);
redirectToDefaultHost((HttpServletResponse) response, httpRequest, availableHosts);
redirectToDefaultHost(response, httpRequest, availableHosts);
return false;
}
} catch (Throwable e) {
log.error(e.getMessage(), e);
redirectToDefaultHost((HttpServletResponse) response, httpRequest, availableHosts);
redirectToDefaultHost(response, httpRequest, availableHosts);
return false;
}
return true;
}
private void redirectToDefaultHost(
@@ -23,10 +23,12 @@ import { FieldDescription } from './FieldDescription.js';
import { FieldLabel } from './FieldLabel.js';
import { FormContext } from './FormContext.js';
import textareaStyle from './Textarea.module.css';
import { useMergeRefs } from '../useMergeRefs.js';
type BaseProps = Omit<React.TextareaHTMLAttributes<HTMLTextAreaElement>, 'onChange' | 'style'> &
ILayoutSizeProps & {
description?: string;
ref?: React.Ref<HTMLTextAreaElement | null>;
description?: React.ReactNode;
labelTooltip?: string;
embedded?: boolean;
cursorInitiallyAtEnd?: boolean;
@@ -53,6 +55,7 @@ interface TextareaType {
}
export const Textarea: TextareaType = observer(function Textarea({
ref,
name,
value: controlledValue,
state,
@@ -71,6 +74,7 @@ export const Textarea: TextareaType = observer(function Textarea({
const translate = useTranslate();
const inputId = useId();
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
const mergedRef = useMergeRefs(...[textareaRef, ref!].filter(Boolean));
const layoutProps = getLayoutProps(rest);
rest = filterLayoutFakeProps(rest);
const styles = useS(textareaStyle);
@@ -108,7 +112,7 @@ export const Textarea: TextareaType = observer(function Textarea({
</FieldLabel>
<textarea
{...rest}
ref={textareaRef}
ref={mergedRef}
id={inputId}
required={required}
className={s(styles, { textarea: true })}
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -13,15 +13,17 @@ import { useTranslate } from '../localization/useTranslate.js';
import { useExecutor } from '../useExecutor.js';
import { FormContext } from './FormContext.js';
export function useCustomInputValidation<T = void>(validation: (value: T) => string | null): React.RefObject<HTMLInputElement | null> {
export function useCustomInputValidation<T = void, TType extends HTMLInputElement | HTMLTextAreaElement = HTMLInputElement>(
validation: (value: T) => string | null,
): React.RefObject<TType | null> {
const context = useContext(FormContext);
const inputRef = useRef<HTMLInputElement>(null);
const inputRef = useRef<TType | null>(null);
const translate = useTranslate();
function validate(element: HTMLInputElement): boolean {
function validate(element: TType): boolean {
let value: T = undefined as unknown as T;
if (element instanceof HTMLInputElement) {
if (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement) {
value = element.value as unknown as T;
}
@@ -64,14 +66,14 @@ export function useCustomInputValidation<T = void>(validation: (value: T) => str
}
function handleInput(event: Event) {
const target = event.target as HTMLInputElement;
const target = event.target as TType;
if (target.validity.valid === false) {
validate(target);
}
}
function handleBlur(event: Event) {
const target = event.target as HTMLInputElement;
const target = event.target as TType;
if (target.validity.valid === true) {
validate(target);
}
+1
View File
@@ -31,6 +31,7 @@ export * from './getPathName.js';
export * from './getPathParent.js';
export * from './getPathParents.js';
export * from './getPathParts.js';
export * from './isIp.js';
export * from './GlobalConstants.js';
export * from './ILoadableState.js';
export * from './errorOf.js';
+17
View File
@@ -0,0 +1,17 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
*/
export function isIp(host: string): boolean {
// Matches IPv4 like 127.0.0.1
const ipv4 = /^(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)\.(25[0-5]|2[0-4]\d|1\d\d|\d\d?)$/;
// Matches IPv6 like [2001:0db8::1]
const ipv6 = /^\[?([a-f0-9:]+)\]?$/i;
return ipv4.test(host) || ipv6.test(host);
}
@@ -74,7 +74,7 @@ export class ConfigurationWizardPagesBootstrapService extends Bootstrap {
onConfigurationFinish: async () => {
const state = this.serverConfigurationFormStateManager.formState;
if (state) {
const saved = await state.save();
const saved = await this.serverConfigurationFormStateManager.save();
if (!saved) {
const error = getFirstException(state.exception);
@@ -7,12 +7,24 @@
*/
import { observer } from 'mobx-react-lite';
import { Group, GroupTitle, Link, IconOrImage, InputField, Switch, Textarea, useResource, useTranslate } from '@cloudbeaver/core-blocks';
import {
Group,
GroupTitle,
Link,
IconOrImage,
InputField,
Switch,
Textarea,
useResource,
useTranslate,
useCustomInputValidation,
} from '@cloudbeaver/core-blocks';
import { ServerConfigResource } from '@cloudbeaver/core-root';
import type { IServerConfigurationPageState } from '../IServerConfigurationPageState.js';
import { MIN_SESSION_EXPIRE_TIME } from './MIN_SESSION_EXPIRE_TIME.js';
import { WEBSITE_LINKS } from '@cloudbeaver/core-links';
import { isIp } from '@cloudbeaver/core-utils';
interface Props {
state: IServerConfigurationPageState;
@@ -21,6 +33,15 @@ interface Props {
export const ServerConfigurationInfoForm = observer<Props>(function ServerConfigurationInfoForm({ state }) {
const serverConfigLoader = useResource(ServerConfigurationInfoForm, ServerConfigResource, undefined);
const translate = useTranslate();
const validation = useCustomInputValidation<string, HTMLTextAreaElement>(value => {
const currentHost = window.location.host;
if (!isIp(window.location.hostname) && value.trim() && !value.includes(currentHost)) {
return translate('administration_configuration_wizard_configuration_supported_hosts_warning', undefined, { host: currentHost });
}
return null;
});
function constructSupportedHostsExample() {
const exampleWithPort = serverConfigLoader.data?.distributed ? 'localhost' : 'localhost:5000';
@@ -35,6 +56,7 @@ export const ServerConfigurationInfoForm = observer<Props>(function ServerConfig
{translate('administration_configuration_wizard_configuration_server_name')}
</InputField>
<Textarea
ref={validation}
title={translate('administration_configuration_wizard_configuration_server_url_description')}
name="supportedHosts"
rows={3}
@@ -11,8 +11,9 @@ import { DEFAULT_NAVIGATOR_VIEW_SETTINGS } from '@cloudbeaver/core-connections';
import { ExecutorInterrupter, type IExecutionContextProvider } from '@cloudbeaver/core-executor';
import { CachedMapAllKey } from '@cloudbeaver/core-resource';
import { DefaultNavigatorSettingsResource, PasswordPolicyResource, ProductInfoResource, ServerConfigResource } from '@cloudbeaver/core-root';
import { FormPart, type IFormState } from '@cloudbeaver/core-ui';
import { isObjectsEqual, isValuesEqual } from '@cloudbeaver/core-utils';
import { FormPart, formValidationContext, type IFormState } from '@cloudbeaver/core-ui';
import { isIp, isObjectsEqual, isValuesEqual } from '@cloudbeaver/core-utils';
import { LocalizationService } from '@cloudbeaver/core-localization';
import { MIN_SESSION_EXPIRE_TIME } from './Form/MIN_SESSION_EXPIRE_TIME.js';
import type { IServerConfigurationFormPartState } from './IServerConfigurationFormPartState.js';
@@ -51,6 +52,7 @@ export class ServerConfigurationFormPart extends FormPart<IServerConfigurationFo
private readonly authProvidersResource: AuthProvidersResource,
private readonly passwordPolicyResource: PasswordPolicyResource,
private readonly passwordPolicyService: PasswordPolicyService,
private readonly localizationService: LocalizationService,
) {
super(formState, DEFAULT_STATE_GETTER());
}
@@ -67,6 +69,19 @@ export class ServerConfigurationFormPart extends FormPart<IServerConfigurationFo
data: IFormState<IServerConfigurationFormPartState>,
contexts: IExecutionContextProvider<IFormState<IServerConfigurationFormPartState>>,
) {
const validation = contexts.getContext(formValidationContext);
const supportedHosts = this.state.serverConfig.supportedHosts;
const currentHost = window.location.host;
if (!isIp(window.location.hostname) && supportedHosts.trim() && !supportedHosts.includes(currentHost)) {
validation.error(
this.localizationService.translate('administration_configuration_wizard_configuration_supported_hosts_warning', undefined, {
host: currentHost,
}),
);
}
if (this.administrationScreenService.isConfigurationMode) {
await this.authProvidersResource.load(CachedMapAllKey);
@@ -8,6 +8,7 @@
import { makeObservable, observable } from 'mobx';
import { injectable, IServiceProvider } from '@cloudbeaver/core-di';
import type { IFormState } from '@cloudbeaver/core-ui';
import { ServerConfigurationFormService } from './ServerConfigurationFormService.js';
import { ServerConfigurationFormState } from './ServerConfigurationFormState.js';
@@ -27,7 +28,7 @@ export class ServerConfigurationFormStateManager {
});
}
create() {
create(): IFormState<null> {
if (this.formState) {
return this.formState;
}
@@ -36,7 +37,15 @@ export class ServerConfigurationFormStateManager {
return this.formState;
}
destroy() {
async save(): Promise<boolean> {
if (!this.formState) {
return false;
}
return await this.formState.save();
}
destroy(): void {
if (this.formState) {
this.formState?.dispose();
this.formState = null;
@@ -88,7 +88,7 @@ export const ServerConfigurationPage: AdministrationItemContentComponent = obser
}
}
const saved = await formState.save();
const saved = await serverConfigurationFormStateManager.save();
if (!saved) {
const error = getFirstException(part.exception);
@@ -1,6 +1,6 @@
/*
* CloudBeaver - Cloud Database Manager
* Copyright (C) 2020-2024 DBeaver Corp and others
* Copyright (C) 2020-2025 DBeaver Corp and others
*
* Licensed under the Apache License, Version 2.0.
* you may not use this file except in compliance with the License.
@@ -10,6 +10,7 @@ import { AuthProvidersResource, PasswordPolicyService } from '@cloudbeaver/core-
import { createDataContext, DATA_CONTEXT_DI_PROVIDER } from '@cloudbeaver/core-data-context';
import { DefaultNavigatorSettingsResource, PasswordPolicyResource, ProductInfoResource, ServerConfigResource } from '@cloudbeaver/core-root';
import type { IFormState } from '@cloudbeaver/core-ui';
import { LocalizationService } from '@cloudbeaver/core-localization';
import { ServerConfigurationFormPart } from './ServerConfigurationFormPart.js';
@@ -25,6 +26,7 @@ export function getServerConfigurationFormPart(formState: IFormState<null>): Ser
const authProvidersResource = di.getService(AuthProvidersResource);
const passwordPolicyResource = di.getService(PasswordPolicyResource);
const passwordPolicyService = di.getService(PasswordPolicyService);
const localizationService = di.getService(LocalizationService);
return new ServerConfigurationFormPart(
formState,
@@ -35,6 +37,7 @@ export function getServerConfigurationFormPart(formState: IFormState<null>): Ser
authProvidersResource,
passwordPolicyResource,
passwordPolicyService,
localizationService,
);
});
}
@@ -41,6 +41,10 @@ export default [
'administration_configuration_wizard_configuration_supported_hosts_description',
'You can specify multiple server URLs separated by a new line. An empty value means that all URLs are allowed',
],
[
'administration_configuration_wizard_configuration_supported_hosts_warning',
'You cannot remove your current domain ({arg:host}). Open the server configuration from another allowed domain or IP-address to remove this domain.',
],
['administration_configuration_wizard_configuration_server_info', 'Server Information'],
['administration_configuration_wizard_configuration_server_name', 'Server Name'],
@@ -41,6 +41,10 @@ export default [
'administration_configuration_wizard_configuration_supported_hosts_description',
'You can specify multiple server URLs separated by a new line. An empty value means that all URLs are allowed',
],
[
'administration_configuration_wizard_configuration_supported_hosts_warning',
'You cannot remove your current domain ({arg:host}). Open the server configuration from another allowed domain or IP-address to remove this domain.',
],
['administration_configuration_wizard_configuration_server_info', 'Informations sur le serveur'],
['administration_configuration_wizard_configuration_server_name', 'Nom du serveur'],
@@ -41,6 +41,10 @@ export default [
'administration_configuration_wizard_configuration_supported_hosts_description',
'You can specify multiple server URLs separated by a new line. An empty value means that all URLs are allowed',
],
[
'administration_configuration_wizard_configuration_supported_hosts_warning',
'You cannot remove your current domain ({arg:host}). Open the server configuration from another allowed domain or IP-address to remove this domain.',
],
['administration_configuration_wizard_configuration_server_info', 'Informazioni sul Server'],
['administration_configuration_wizard_configuration_server_name', 'Nome del Server'],
@@ -20,6 +20,10 @@ export default [
'administration_configuration_wizard_configuration_supported_hosts_description',
'Вы можете указать несколько URL серверов, разделенных новой строкой. Пустое значение означает, что все URL разрешены',
],
[
'administration_configuration_wizard_configuration_supported_hosts_warning',
'You cannot remove your current domain ({arg:host}). Open the server configuration from another allowed domain or IP-address to remove this domain.',
],
['administration_configuration_wizard_configuration_server_info', 'Информация о сервере'],
['administration_configuration_wizard_configuration_server_name', 'Название сервера'],
@@ -32,6 +32,10 @@ export default [
'administration_configuration_wizard_configuration_supported_hosts_description',
'You can specify multiple server URLs separated by a new line. An empty value means that all URLs are allowed',
],
[
'administration_configuration_wizard_configuration_supported_hosts_warning',
'You cannot remove your current domain ({arg:host}). Open the server configuration from another allowed domain or IP-address to remove this domain.',
],
['administration_configuration_tools_save_tooltip', 'Lưu cấu hình'],
['administration_configuration_tools_cancel_tooltip', 'Đặt lại thay đổi'],
['administration_configuration_wizard_configuration_server_info', 'Thông tin Server'],
@@ -38,6 +38,10 @@ export default [
'administration_configuration_wizard_configuration_supported_hosts_description',
'You can specify multiple server URLs separated by a new line. An empty value means that all URLs are allowed',
],
[
'administration_configuration_wizard_configuration_supported_hosts_warning',
'You cannot remove your current domain ({arg:host}). Open the server configuration from another allowed domain or IP-address to remove this domain.',
],
['administration_configuration_wizard_configuration_server_info', '服务器信息'],
['administration_configuration_wizard_configuration_server_name', '服务器名称'],