-
onChange({ ...item, value: e.target.value })}
- disabled={disabled}
- readOnly={readOnly}
- data-cy="ldap-dn-builder-input"
- />
+
+
+
+
+
+ handleValueChange(e.target.value)}
+ disabled={disabled}
+ readOnly={readOnly}
+ data-cy="ldap-dn-builder-input"
+ />
+
+ {error &&
{error}}
);
}
diff --git a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupDnBuilder.tsx b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupDnBuilder.tsx
index 1b90a27211..d93efd1263 100644
--- a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupDnBuilder.tsx
+++ b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupDnBuilder.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useState } from 'react';
+import { useEffect, useRef, useState } from 'react';
import { FeatureId } from '@/react/portainer/feature-flags/enums';
import { isLimitedToBE } from '@/react/portainer/feature-flags/feature-flags.service';
@@ -32,37 +32,43 @@ export function GroupDnBuilder({
parseDN(parsePath(value, suffix), suffix)
);
+ // The DN string can't represent an empty (in-progress) group name or path
+ // row, so re-parsing our own emitted value would drop it — making the field
+ // vanish as you clear its text. Track what we emitted so we re-sync only when
+ // `value` changes from an external source (initial load, reset).
+ const emittedRef = useRef(buildGroupDN(groupName, entries, suffix));
+
+ useEffect(() => {
+ if (value !== emittedRef.current) {
+ const parsedGroupName = parseGroupName(value, suffix);
+ const parsedEntries = parseDN(parsePath(value, suffix), suffix);
+ emittedRef.current = buildGroupDN(parsedGroupName, parsedEntries, suffix);
+ setGroupName(parsedGroupName);
+ setEntries(parsedEntries);
+ }
+ }, [value, suffix]);
+
+ // Keep the emitted DN in sync with the group name, path entries and suffix.
useEffect(() => {
- const groupName = parseGroupName(value, suffix);
- const entries = parseDN(parsePath(value, suffix), suffix);
- setGroupName(groupName);
- setEntries(entries);
const dn = buildGroupDN(groupName, entries, suffix);
- if (dn !== value) {
+ if (dn !== emittedRef.current) {
+ emittedRef.current = dn;
onChange(index, dn);
}
- }, [index, onChange, suffix, value]);
+ }, [groupName, entries, suffix, index, onChange]);
return (
<>
{
- setGroupName(newGroupName);
- onChange(index, buildGroupDN(newGroupName, entries, suffix));
- }}
+ onChange={setGroupName}
disabled={isLimited}
onRemoveClick={onRemoveClick ? () => onRemoveClick(index) : undefined}
/>
{
- setEntries(entries);
- if (groupName) {
- onChange(index, buildGroupDN(groupName, entries, suffix));
- }
- }}
+ onChange={setEntries}
label="Path to group"
limitedFeatureId={limitedFeatureId}
/>
diff --git a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupNameField.tsx b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupNameField.tsx
index e03f3603f7..000f546362 100644
--- a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupNameField.tsx
+++ b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/GroupNameField.tsx
@@ -23,32 +23,32 @@ export function GroupNameField({
-
- onChange(e.target.value)}
- disabled={disabled}
- readOnly={disabled}
- />
-
- {onRemoveClick && (
-
-
+
+
+
+ onChange(e.target.value)}
+ disabled={disabled}
+ />
+
+ {onRemoveClick && (
+
+ )}
- )}
+
);
}
diff --git a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.test.ts b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.test.ts
index 4d4c3c16c0..47221ea2a1 100644
--- a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.test.ts
+++ b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.test.ts
@@ -1,6 +1,11 @@
import { describe, it, expect } from 'vitest';
-import { parseDN, buildDN, DnEntry } from './ldap-dn-utils';
+import {
+ parseDN,
+ buildDN,
+ validateDnEntryValue,
+ DnEntry,
+} from './ldap-dn-utils';
describe('parseDN', () => {
it('should parse DN with OU entries', () => {
@@ -79,6 +84,25 @@ describe('parseDN', () => {
{ type: 'ou', value: 'dept_2' },
]);
});
+
+ it('should preserve special characters in values', () => {
+ const result = parseDN(
+ 'ou=R&D (Eng.),ou=Sales+Mktg,dc=example,dc=com',
+ 'dc=example,dc=com'
+ );
+
+ expect(result).toEqual([
+ { type: 'ou', value: 'R&D (Eng.)' },
+ { type: 'ou', value: 'Sales+Mktg' },
+ ]);
+ });
+
+ it('should round-trip values containing special characters', () => {
+ const dn = 'ou=R&D (Eng.),dc=example,dc=com';
+ const suffix = 'dc=example,dc=com';
+
+ expect(buildDN(parseDN(dn, suffix), suffix)).toBe(dn);
+ });
});
describe('buildDN', () => {
@@ -152,3 +176,63 @@ describe('buildDN', () => {
expect(result).toBe('');
});
});
+
+describe('validateDnEntryValue', () => {
+ it('should accept an empty value', () => {
+ expect(validateDnEntryValue('')).toBeUndefined();
+ });
+
+ it('should accept values with allowed characters', () => {
+ expect(validateDnEntryValue('R&D (Eng.)')).toBeUndefined();
+ expect(validateDnEntryValue('Sales_Team 01')).toBeUndefined();
+ });
+
+ it('should accept an equals sign (escaped only in the type, not the value)', () => {
+ expect(validateDnEntryValue('a=b')).toBeUndefined();
+ });
+
+ it('should accept non-ASCII characters', () => {
+ expect(validateDnEntryValue('Café Müller')).toBeUndefined();
+ });
+
+ it('should accept an interior number sign', () => {
+ expect(validateDnEntryValue('Team#1')).toBeUndefined();
+ });
+
+ it('should reject each reserved character', () => {
+ expect(validateDnEntryValue('a"b')).toContain('"');
+ expect(validateDnEntryValue('a+b')).toContain('+');
+ expect(validateDnEntryValue('a,b')).toContain(',');
+ expect(validateDnEntryValue('a;b')).toContain(';');
+ expect(validateDnEntryValue('ab')).toContain('>');
+ expect(validateDnEntryValue('a\\b')).toContain('\\');
+ });
+
+ it('should list every reserved character it finds', () => {
+ expect(validateDnEntryValue('a,b+c')).toBe(
+ 'These characters are not allowed in a DN entry value: + ,'
+ );
+ });
+
+ it('should reject a leading space or number sign', () => {
+ expect(validateDnEntryValue(' Users')).toBe(
+ 'A DN entry value cannot start with a space or "#".'
+ );
+ expect(validateDnEntryValue('#Users')).toBe(
+ 'A DN entry value cannot start with a space or "#".'
+ );
+ });
+
+ it('should reject a trailing space', () => {
+ expect(validateDnEntryValue('Users ')).toBe(
+ 'A DN entry value cannot end with a space.'
+ );
+ });
+
+ it('should reject control characters', () => {
+ expect(validateDnEntryValue('a\tb')).toBe(
+ 'A DN entry value cannot contain control characters.'
+ );
+ });
+});
diff --git a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.ts b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.ts
index e95969fb9c..bd371734d1 100644
--- a/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.ts
+++ b/app/react/portainer/settings/AuthenticationView/LDAPAuth/DnEntriesField/ldap-dn-utils.ts
@@ -3,11 +3,53 @@ export interface DnEntry {
value: string;
}
+// Characters that RFC 4514 requires to be escaped anywhere inside a
+// distinguished-name attribute value, mirroring go-ldap's EscapeDN (the
+// library the backend authenticates with). Portainer's DN builder concatenates
+// values into the DN string without escaping, so an unescaped occurrence would
+// corrupt the DN — we reject them up front with a warning instead.
+// Note: '=' is intentionally absent (it only needs escaping in the type, not
+// the value) and non-ASCII characters are allowed (valid UTF-8 in a DN).
+const RESERVED_DN_VALUE_CHARS = ['"', '+', ',', ';', '<', '>', '\\'];
+
+const CONTROL_CHAR_MAX = 0x1f;
+const DELETE_CHAR = 0x7f;
+
+export function validateDnEntryValue(value: string): string | undefined {
+ const reserved = RESERVED_DN_VALUE_CHARS.filter((char) =>
+ value.includes(char)
+ );
+ if (reserved.length > 0) {
+ return `These characters are not allowed in a DN entry value: ${reserved.join(
+ ' '
+ )}`;
+ }
+
+ // A leading '#' is read as a hex-encoded value, and leading/trailing spaces
+ // are stripped, so both positions must be escaped (RFC 4514 §2.4).
+ if (value.startsWith(' ') || value.startsWith('#')) {
+ return 'A DN entry value cannot start with a space or "#".';
+ }
+ if (value.endsWith(' ')) {
+ return 'A DN entry value cannot end with a space.';
+ }
+
+ const hasControlChar = [...value].some((char) => {
+ const code = char.charCodeAt(0);
+ return code <= CONTROL_CHAR_MAX || code === DELETE_CHAR;
+ });
+ if (hasControlChar) {
+ return 'A DN entry value cannot contain control characters.';
+ }
+
+ return undefined;
+}
+
export function parseDN(
dn: string | undefined,
domainSuffix: string
): DnEntry[] {
- const regex = /(\w+)=([a-zA-Z0-9_ -]*),?/;
+ const regex = /(\w+)=([^,]*),?/;
const ouValues: DnEntry[] = [];
let left = dn || '';
let match = left.match(regex);