Improve Gateway authentication and static HTTP fidelity

This commit is contained in:
StasonJatham committed 2026-10-07 07:32:34 +02:00
1 parent 42561bfa34
commit 4ab9411aca
8 files changed
+167 -58

No files matched your search

+2 -2
View File
@@ -55,7 +55,7 @@ English/German language JSON and gzip endpoints carry the default 14.1-29.63 per
Translations are synthetic subsets, not original firmware files. If changing HONEYPOT_VERSION, also
set HONEYPOT_ASSET_MTIME to that firmware's documented timestamp; metadata matching does not prove
full firmware emulation. Gzip assets are returned with Content-Encoding and Last-Modified.
Authentication XML includes credential requirements and a postback route; it grants no real access.
Authentication XML includes credential requirements, state context, a postback route and error labels after credential submissions; it grants no real access. The browser requests the initial challenge and consumes the returned XML. Gateway CSS and JavaScript use local appliance-style paths; assets support ETag/Last-Modified revalidation and HEAD, without issuing session cookies. CSP reports are captured before a204 response; logout clears the cookie and redirects locally.
`request_indicators` hunts header/body log markers and encoding hints without claiming a confirmed CVE.
Bodies are inspected up to64KiB for these hints; complete accepted bodies remain captured independently.
Run `python3 test_profile.py` for passive identification, gzip decoding/metadata, XML and capture checks.
@@ -139,7 +139,7 @@ vulnerability exists, a payload executes, or a CVE is conclusively identified.
| Gateway login pages | `/`, `/vpn/`, `/vpn/index.html`, `/vpn/tmindex.html`, `/logon/LogonPoint/index.html`, `/logon/LogonPoint/tmindex.html`, `/logon/LogonPoint/custom.html`, `/menu/neo/`. German login UI, canonical Citrix Gateway title, NSC_ cookie and Gateway-shaped response headers. |
| Gateway static fingerprints | Explicit rdx/CTXS JavaScript routes, login.js/init.js, original favicon/logo/background assets and AccessGateway.ico alias. Only routes in STATIC are supported; arbitrary files under these directories return404. |
| Language/version fingerprints | English/German JSON and `.json.gz` under `/vpn/js/rdx/core/lang/` and `/vpn/js/rdx/core/lang-ext/`; gzip filename, build timestamp, Content-Encoding and Last-Modified. Synthetic translation subset with the default14.1-29.63 metadata. |
| Authentication XML | `/nf/auth/*` returns credential requirements, state context and local postback. This is a repeated login challenge, not LDAP, SAML, MFA or successful real authentication. |
| Authentication XML | `/nf/auth/getAuthenticationRequirements.do` and `/nf/auth/doAuthentication.do` return credential requirements, state context and local postback; credential submissions add an error label. Unknown authentication endpoints return404. This is a repeated login challenge, not LDAP, SAML, MFA or successful real authentication. |
| NITRO authentication | `/nitro/v1/config/login` and logout with synthetic session tokens. Resource access accepts a matching fake token, nonempty X-NITRO-USER/X-NITRO-PASS, or a Basic authorization prefix. These are decoy rules, not real user/account validation. |
| NITRO resources | `/nitro/v1/config/<resource>[/<name>]` and `/nitro/v1/stat/<resource>[/<name>]`;1,493 config and176 statistics names in [nitro-resources.json](nitro-resources.json). Common system/network/Gateway/AAA/SSL/LB/binding records are populated; other recognized resources return empty collections or generic counters. |
| NITRO operations | Config reads, fake POST/PUT/DELETE, enable/disable/rename, inert administrative-action acknowledgements, attrs, literal filter/args, counts, pagesize and pageno. Unknown resources, invalid input, unsupported methods and unauthenticated access return simulator errors. |
+57 -16
View File
@@ -7,6 +7,7 @@ import collections
import gzip
import email.parser
import email.policy
import email.utils
import hashlib
import html
import http.server
@@ -51,21 +52,19 @@ STATIC = {
'/logon/LogonPoint/receiver/images/common/icon_vpn.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'),
'/vpn/images/AccessGateway.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'),
'/favicon.ico': ((UI / 'favicon.ico').read_bytes(), 'image/vnd.microsoft.icon'),
# Static NetScaler Gateway scripts scanners look for; symbols match the real rdx/CTXS namespace.
'/vpn/js/rdx.js': (b"window.CTXS=window.CTXS||{};window.rdx=window.rdx||{};"
b"rdx.base_view=function(){};rdx.utils={get_view:function(){}};\n", JS),
'/vpn/js/rdx/core/rdx.js': (b"window.rdx=window.rdx||{};rdx.base_view=rdx.base_view||function(){};"
b"rdx.utils={get_view:function(){}};\n", JS),
'/vpn/js/gateway_login_view.js': (b"rdx.gateway_login_view=function(){return new rdx.base_view();};\n", JS),
'/vpn/js/gateway_login_form_view.js': (b"rdx.gateway_login_form_view=function(){return rdx.utils.get_view();};\n", JS),
'/logon/LogonPoint/init.js': (b"var _ctxstxt_CitrixCopyright='Copyright (c) Citrix Systems, Inc.';"
b"window.CTXS=window.CTXS||{};CTXS.init=function(){};\n", JS),
'/logon/LogonPoint/receiver/js/ctxs.core.min.js': (b"window.CTXS=window.CTXS||{};CTXS.core={version:'1.0'};\n", JS),
'/vpn/js/rdx/core/css/rdx.css': ((UI / 'rdx.css').read_bytes(), 'text/css; charset=utf-8'),
'/vpn/js/rdx.js': ((UI / 'rdx.js').read_bytes(), JS),
'/vpn/js/rdx/core/rdx.js': ((UI / 'rdx.js').read_bytes(), JS),
'/vpn/js/gateway_login_view.js': (b"rdx.gateway_login_view=function(){return new rdx.base_view(document.querySelector('main'));};\n", JS),
'/vpn/js/gateway_login_form_view.js': (b"rdx.gateway_login_form_view=function(){return new rdx.base_view(document.getElementById('login-form'));};\n", JS),
'/logon/LogonPoint/init.js': (b"window.CTXS=window.CTXS||{};CTXS.init=function(){return rdx.utils.get_view('login-form');};\n", JS),
'/logon/LogonPoint/receiver/js/ctxs.core.min.js': ((UI / 'rdx.js').read_bytes(), JS),
}
# Synthetic translations; metadata matches the default 14.1-29.63 persona.
# Source: Fox-IT citrix-netscaler-triage version timestamp catalog.
ASSET_MTIME = env_int('HONEYPOT_ASSET_MTIME', 1723114781)
UI_MTIME = int(max(p.stat().st_mtime for p in UI.iterdir() if p.is_file()))
for language in ('en', 'de'):
translation = {'LogOn': 'Log On' if language == 'en' else 'Anmelden',
'UserName': 'User name' if language == 'en' else 'Benutzername',
@@ -681,13 +680,18 @@ class Handler(http.server.BaseHTTPRequestHandler):
data, mime = STATIC[url.path]
if url.path.endswith('.gz'):
extra_headers.append(('Content-Encoding', 'gzip'))
extra_headers.append(('Last-Modified', self.date_time_string(ASSET_MTIME)))
elif url.path.startswith('/nf/auth/'):
asset_mtime = ASSET_MTIME if '/lang/' in url.path or '/lang-ext/' in url.path else UI_MTIME
extra_headers.append(('Last-Modified', self.date_time_string(asset_mtime)))
elif url.path in ('/nf/auth/getAuthenticationRequirements.do', '/nf/auth/doAuthentication.do'):
data = ('<AuthenticateResponse xmlns="http://citrix.com/authentication/response/1">'
'<Status>success</Status><Result>more-info</Result>'
'<StateContext>' + html.escape(state.sid) + '</StateContext>'
'<AuthenticationRequirements><PostBack>/nf/auth/doAuthentication.do</PostBack>'
'<Requirements>'
'<Requirements>' + (
'<Requirement><Credential><Type>none</Type></Credential>'
'<Label><Text>Incorrect user name or password.</Text><Type>error</Type></Label>'
'<Input/></Requirement>' if url.path == '/nf/auth/doAuthentication.do'
and self.command == 'POST' and auth_fields.intersection({'login', 'passwd'}) else '') +
'<Requirement><Credential><ID>login</ID><Type>username</Type></Credential>'
'<Label><Text>User name:</Text><Type>plain</Type></Label>'
'<Input><Text><InitialValue/><Constraint>.+</Constraint></Text></Input></Requirement>'
@@ -699,6 +703,12 @@ class Handler(http.server.BaseHTTPRequestHandler):
'</Requirements></AuthenticationRequirements>'
'</AuthenticateResponse>').encode()
mime = 'application/vnd.citrix.authenticateresponse-1+xml; charset=utf-8'
elif url.path == '/nscsp_violation/report_uri':
code, data, mime = 204, b'', 'text/plain; charset=utf-8'
elif url.path in ('/cgi/logout', '/vpn/logout.html'):
code, data = 302, b''
extra_headers = [('Location', '/vpn/index.html'),
('Set-Cookie', 'NSC_AAAC=; Path=/; Max-Age=0; Secure; HttpOnly')]
elif url.path.startswith('/nitro/'):
code, result, extra_headers = nitro_response(state, self.command, url, body, self.headers)
data, mime = json.dumps(result).encode(), 'application/json'
@@ -715,22 +725,53 @@ class Handler(http.server.BaseHTTPRequestHandler):
self.event('virtual_file_http_read', path=key, content=data)
else:
code, data = 404, b'Not Found'
# Capture and ingest precede protocol responses, including rejected methods.
if url.path in STATIC or url.path in LOGIN_PATHS:
allowed = ('GET', 'HEAD')
elif url.path == '/nf/auth/doAuthentication.do' or url.path == '/nscsp_violation/report_uri':
allowed = ('POST',)
elif url.path == '/nf/auth/getAuthenticationRequirements.do':
allowed = ('GET', 'HEAD', 'POST')
else:
allowed = None
if allowed and self.command not in allowed:
code, data, mime = 405, b'Method Not Allowed', 'text/plain; charset=utf-8'
extra_headers = [('Allow', ', '.join(allowed))]
if url.path in STATIC and code == 200:
etag = '"' + hashlib.sha256(data).hexdigest() + '"'
extra_headers.append(('ETag', etag))
matches = self.headers.get('If-None-Match')
not_modified = matches is not None and any(
tag.strip().removeprefix('W/') in ('*', etag) for tag in matches.split(','))
if matches is None and self.headers.get('If-Modified-Since'):
try:
not_modified = email.utils.parsedate_to_datetime(
self.headers['If-Modified-Since']).timestamp() >= asset_mtime
except (ValueError, TypeError, OverflowError):
pass
if not_modified:
code, data = 304, b''
if vm_read_path:
result = engine.backend.read(vm_read_path, self.context)
if result is not None:
code, data, mime = 200, result, 'text/plain; charset=utf-8'
# Headers shaped like the real NetScaler Gateway AAA response; HTTP/1.1 keep-alive is default.
headers = [('Content-Type', mime), ('Content-Length', str(len(data))),
('Cache-Control', 'no-cache, no-store, must-revalidate'),
headers = [('Content-Type', mime),
('Cache-Control', 'public, max-age=0, must-revalidate' if url.path in STATIC
and code in (200, 304) else 'no-cache, no-store, must-revalidate'),
('Pragma', 'no-cache'), ('Expires', '0'),
('X-Frame-Options', 'SAMEORIGIN'), ('X-Content-Type-Options', 'nosniff'),
('X-XSS-Protection', '1; mode=block'),
('Set-Cookie', f'NSC_AAAC={state.sid}; Path=/; Secure; HttpOnly'),
('Content-Security-Policy',
"default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; "
"img-src 'self' data:; font-src 'self'; frame-ancestors 'self'; "
"connect-src 'self' citrixng:// com.citrix.nsgclient:// nsgcepa://; "
"report-uri /nscsp_violation/report_uri")]
if code not in (204, 304):
headers.append(('Content-Length', str(len(data))))
if url.path not in STATIC and url.path != '/nscsp_violation/report_uri' and not any(
key == 'Set-Cookie' for key, _ in extra_headers):
headers.append(('Set-Cookie', f'NSC_AAAC={state.sid}; Path=/; Secure; HttpOnly'))
headers.extend(extra_headers)
self.event('response', status=code, headers=headers, content=data,
body_suppressed=self.command == 'HEAD')
+1 -1
View File
@@ -101,7 +101,7 @@ def main():
wire_digest = hashlib.sha256(raw).hexdigest()
# Wait for finally-recorded evidence after the response has closed.
assert (pathlib.Path(workspace.name) / 'artifacts' / (wire_digest + '.bin')).read_bytes() == raw
assert b'200' in raw_request(b'CUSTOM / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n').split(b'\r\n')[0]
assert b'405' in raw_request(b'CUSTOM / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n').split(b'\r\n')[0]
assert b'400' in raw_request(b'not-http\r\n')
partial = b'PUT / HTTP/1.1\r\nHost: localhost\r\nContent-Length: 10\r\n\r\nabc'
assert b'400' in raw_request(partial)
+34
View File
@@ -43,6 +43,40 @@ try:
ns = {'c': 'http://citrix.com/authentication/response/1'}
assert root.find('c:AuthenticationRequirements/c:PostBack', ns).text == '/nf/auth/doAuthentication.do'
assert len(root.findall('.//c:Requirement', ns)) == 3
status, headers, xml = request('/nf/auth/doAuthentication.do', 'POST',
b'login=profile-check&passwd=synthetic',
{'Content-Type': 'application/x-www-form-urlencoded'})
root = ET.fromstring(xml)
labels = root.findall('.//c:Label', ns)
assert any(label.find('c:Type', ns).text == 'error' for label in labels)
assert root.find('c:Result', ns).text == 'more-info' # Never grant authentication.
assert request('/nf/auth/not-a-real-endpoint.do')[0] == 404
assert request('/nf/auth/doAuthentication.do')[0] == 405
assert b'<script>' not in html and b'<style>' not in html # Compatible with our CSP.
for path in ('/vpn/js/rdx/core/css/rdx.css', '/vpn/js/rdx.js', '/logon/LogonPoint/login.js'):
status, headers, asset = request(path)
assert status == 200 and asset and 'Set-Cookie' not in headers
assert 'no-store' not in headers['Cache-Control']
assert request(path, headers={'If-None-Match': headers['ETag']})[0] == 304
assert request(path, headers={'If-None-Match': 'W/' + headers['ETag']})[0] == 304
assert request(path, headers={'If-Modified-Since': headers['Last-Modified']})[0] == 304
assert request(path, headers={'If-Modified-Since': 'invalid'})[0] == 200
assert request(path, headers={'If-None-Match': '"different"',
'If-Modified-Since': headers['Last-Modified']})[0] == 200
head = request(path, 'HEAD')
assert head[0] == 200 and head[2] == b'' and int(head[1]['Content-Length']) == len(asset)
before = len(events)
status, rejected_headers, _ = request(path, 'POST', b'capture-only-check-method')
assert status == 405 and rejected_headers['Allow'] == 'GET, HEAD'
assert any(e['event'] == 'request_body' and e['evidence']['bytes'] == 25 for e in events[before:])
before = len(events)
assert request('/nscsp_violation/report_uri', 'POST', b'{}',
{'Content-Type': 'application/csp-report'})[0] == 204
assert any(e['event'] == 'request_body' and e['evidence']['bytes'] == 2 for e in events[before:])
assert request('/nscsp_violation/report_uri')[0] == 405
status, headers, body = request('/cgi/logout')
assert status == 302 and headers['Location'] == '/vpn/index.html' and body == b''
assert 'Max-Age=0' in headers['Set-Cookie']
# Inert markers, no exploit syntax or commands. Original intake still correlated.
status, _, _ = request('/unknown', 'POST', b'base64=inert-marker',
{'Content-Type': 'application/x-www-form-urlencoded', 'X_Test': 'NSPPE inert-marker'})
+2 -33
View File
@@ -7,42 +7,10 @@
<title class="_ctxstxt_NetscalerGateway">Citrix Gateway</title>
<link rel="icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" sizes="16x16 32x32 48x48 64x64" type="image/vnd.microsoft.icon">
<link rel="shortcut icon" href="/logon/LogonPoint/receiver/images/common/icon_vpn.ico" type="image/vnd.microsoft.icon">
<style>
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body { background: #302c41 url('/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg') center / cover fixed;
font-family: "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; color: #fff; }
main { min-height: 100vh; display: flex; align-items: center; }
.band { width: 100%; min-height: 350px; background: rgba(58, 54, 65, .86); display: flex; align-items: center; }
.layout { width: min(1086px, calc(100% - 64px)); margin: auto; display: grid; grid-template-columns: 258px 570px; align-items: center; }
.logo { width: 160px; height: auto; }
h1 { font-size: 18px; font-weight: 400; margin: 0 0 18px; }
.row { display: grid; grid-template-columns: 185px 1fr; align-items: center; margin-bottom: 6px; }
label { color: #bdbbc2; font-size: 16px; }
input { width: 100%; height: 44px; border: 1px solid #ddd; background: white; color: #111; border-radius: 0; padding: 8px 15px; font: inherit; }
input:focus-visible { outline: 2px solid #79cce2; outline-offset: 2px; }
button { display: block; width: calc(100% - 185px); margin: 26px 0 0 185px; height: 45px; border: 0; border-radius: 0;
background: #49a0bb; color: white; font: 600 16px "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; cursor: pointer; }
button:hover { background: #398aa4; }
button:focus-visible { outline: 2px solid white; outline-offset: 3px; }
button:disabled { cursor: wait; opacity: .75; }
#message { margin: 12px 0 0 185px; font-size: 14px; color: #fff; }
#message:empty { display: none; }
@media (max-width: 950px) {
.layout { width: min(570px, calc(100% - 48px)); display: block; padding: 40px 0; }
.logo { margin-bottom: 28px; }
}
@media (max-width: 540px) {
.row { display: block; margin-bottom: 16px; }
label { display: block; margin-bottom: 7px; }
button { width: 100%; margin-left: 0; }
#message { margin-left: 0; }
}
</style>
<link rel="stylesheet" href="/vpn/js/rdx/core/css/rdx.css">
<script src="/vpn/js/rdx.js"></script>
<script src="/vpn/js/gateway_login_view.js"></script>
<script src="/logon/LogonPoint/login.js" defer></script>
<script>var _ctxstxt_CitrixCopyright = 'Copyright (c) Citrix Systems, Inc.';</script>
</head>
<body>
<main>
@@ -53,6 +21,7 @@
<h1>Melden Sie sich an</h1>
<div class="row"><label for="login">Benutzername:</label><input id="login" name="login" required autofocus autocomplete="off" spellcheck="false"></div>
<div class="row"><label for="passwd">Kennwort:</label><input id="passwd" name="passwd" type="password" required autocomplete="off"></div>
<input type="hidden" name="StateContext" value="">
<input type="hidden" name="savecredentials" value="false">
<button id="nsg-x1-logon-button" name="nsg-x1-logon-button" value="Log On" type="submit">Anmelden</button>
<p id="message" role="status" aria-live="polite"></p>
+18 -6
View File
@@ -2,19 +2,31 @@
const form = document.getElementById('login-form');
const button = document.getElementById('nsg-x1-logon-button');
const message = document.getElementById('message');
async function authenticate(path, body) {
const response = await fetch(path, {
method: 'POST', body, credentials: 'same-origin', redirect: 'error',
headers: { Accept: 'application/vnd.citrix.authenticateresponse-1+xml' },
signal: AbortSignal.timeout(10000)
});
if (!response.ok) throw new Error('Request failed');
const result = rdx.utils.authentication(await response.text());
form.elements.StateContext.value = result.state;
// Only the local authentication endpoint can receive form credentials.
if (result.postback === '/nf/auth/doAuthentication.do') form.action = result.postback;
return result;
}
const ready = authenticate('/nf/auth/getAuthenticationRequirements.do', new URLSearchParams())
.catch(() => null);
form.addEventListener('submit', async event => {
event.preventDefault();
button.disabled = true;
message.textContent = '';
try {
await ready;
const body = new URLSearchParams(new FormData(form));
body.set('nsg-x1-logon-button', 'Log On');
const response = await fetch('/nf/auth/doAuthentication.do', {
method: 'POST', body, credentials: 'same-origin', redirect: 'error',
signal: AbortSignal.timeout(10000)
});
if (!response.ok) throw new Error('Request failed');
message.textContent = 'Anmeldung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.';
const result = await authenticate('/nf/auth/doAuthentication.do', body);
message.textContent = result.errors.join(' ') || 'Anmeldung fehlgeschlagen. Überprüfen Sie Ihre Zugangsdaten.';
} catch {
message.textContent = 'Die Anmeldung ist derzeit nicht möglich. Versuchen Sie es erneut.';
} finally {
+30
View File
@@ -0,0 +1,30 @@
* { box-sizing: border-box; }
html, body { min-height: 100%; margin: 0; }
body { background: #302c41 url('/logon/LogonPoint/receiver/images/common/ReceiverFullScreenBackground.jpg') center / cover fixed;
font-family: "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; color: #fff; }
main { min-height: 100vh; display: flex; align-items: center; }
.band { width: 100%; min-height: 350px; background: rgba(58, 54, 65, .86); display: flex; align-items: center; }
.layout { width: min(1086px, calc(100% - 64px)); margin: auto; display: grid; grid-template-columns: 258px 570px; align-items: center; }
.logo { width: 160px; height: auto; }
h1 { font-size: 18px; font-weight: 400; margin: 0 0 18px; }
.row { display: grid; grid-template-columns: 185px 1fr; align-items: center; margin-bottom: 6px; }
label { color: #bdbbc2; font-size: 16px; }
input { width: 100%; height: 44px; border: 1px solid #ddd; background: white; color: #111; border-radius: 0; padding: 8px 15px; font: inherit; }
input:focus-visible { outline: 2px solid #79cce2; outline-offset: 2px; }
button { display: block; width: calc(100% - 185px); margin: 26px 0 0 185px; height: 45px; border: 0; border-radius: 0;
background: #49a0bb; color: white; font: 600 16px "Helvetica Neue", "Segoe UI", Helvetica, Arial, sans-serif; cursor: pointer; }
button:hover { background: #398aa4; }
button:focus-visible { outline: 2px solid white; outline-offset: 3px; }
button:disabled { cursor: wait; opacity: .75; }
#message { margin: 12px 0 0 185px; font-size: 14px; color: #fff; }
#message:empty { display: none; }
@media (max-width: 950px) {
.layout { width: min(570px, calc(100% - 48px)); display: block; padding: 40px 0; }
.logo { margin-bottom: 28px; }
}
@media (max-width: 540px) {
.row { display: block; margin-bottom: 16px; }
label { display: block; margin-bottom: 7px; }
button { width: 100%; margin-left: 0; }
#message { margin-left: 0; }
}
+23
View File
@@ -0,0 +1,23 @@
'use strict';
var _ctxstxt_CitrixCopyright = 'Copyright (c) Citrix Systems, Inc.';
window.CTXS = window.CTXS || {};
window.rdx = window.rdx || {};
rdx.base_view = function (element) { this.element = element; };
rdx.base_view.prototype.show = function () { if (this.element) this.element.hidden = false; };
rdx.base_view.prototype.hide = function () { if (this.element) this.element.hidden = true; };
rdx.utils = {
get_view: function (id) { return new rdx.base_view(document.getElementById(id || 'login-form')); },
authentication: function (xml) {
const document = new DOMParser().parseFromString(xml, 'application/xml');
const ns = 'http://citrix.com/authentication/response/1';
if (document.getElementsByTagName('parsererror').length ||
document.documentElement.localName !== 'AuthenticateResponse' ||
document.documentElement.namespaceURI !== ns) throw new Error('Invalid authentication response');
const text = name => document.getElementsByTagNameNS(ns, name)[0]?.textContent || '';
const errors = Array.from(document.getElementsByTagNameNS(ns, 'Label'))
.filter(label => label.getElementsByTagNameNS(ns, 'Type')[0]?.textContent === 'error')
.map(label => label.getElementsByTagNameNS(ns, 'Text')[0]?.textContent || '');
return { state: text('StateContext'), postback: text('PostBack'), errors };
}
};
CTXS.core = { getView: rdx.utils.get_view };