diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 44e05628..dc5b4c6b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -16,93 +16,117 @@ permissions:
packages: read
jobs:
- image-name:
- runs-on: ubuntu-24.04
- outputs:
- ci: ${{ steps.set.outputs.ci }}
- steps:
- - id: set
- run: echo "ci=ghcr.io/${GITHUB_REPOSITORY,,}/build:latest" >> "$GITHUB_OUTPUT"
-
test:
- needs: image-name
runs-on: ubuntu-24.04
- container:
- image: ${{ needs.image-name.outputs.ci }}
- credentials:
- username: ${{ github.actor }}
- password: ${{ secrets.GITHUB_TOKEN }}
-
- services:
- mysql:
- image: mysql:8
- env:
- MYSQL_ROOT_PASSWORD: root
- MYSQL_DATABASE: nictool
- options: >-
- --health-cmd="mysqladmin ping -h 127.0.0.1"
- --health-interval=5s
- --health-timeout=3s
- --health-retries=10
-
steps:
- uses: actions/checkout@v6
+ - uses: docker/setup-buildx-action@v4
+
+ - uses: docker/login-action@v4
+ with:
+ registry: ghcr.io
+ username: ${{ github.actor }}
+ password: ${{ secrets.GITHUB_TOKEN }}
+
+ - name: Compute GHCR cache ref
+ id: meta
+ run: echo "ref=ghcr.io/${GITHUB_REPOSITORY,,}/build:latest" >> "$GITHUB_OUTPUT"
+
+ - name: Build CI base image
+ uses: docker/build-push-action@v7
+ with:
+ context: .
+ file: dist/docker/Dockerfile
+ target: base
+ tags: nictool-ci:latest
+ load: true
+ cache-from: |
+ type=registry,ref=${{ steps.meta.outputs.ref }}
+ type=gha,scope=ci
+ cache-to: type=gha,mode=max,scope=ci
+
- name: Generate test credentials
run: |
- DB_PW=$(openssl rand -base64 24)
- ROOT_PW=$(openssl rand -base64 24)
- echo "NICTOOL_DB_USER_PASSWORD=$DB_PW" >> "$GITHUB_ENV"
- echo "DB_ROOT_PASSWORD=root" >> "$GITHUB_ENV"
- echo "NICTOOL_DB_USER=nictool" >> "$GITHUB_ENV"
- echo "NICTOOL_DB_NAME=nictool" >> "$GITHUB_ENV"
- echo "DB_ENGINE=mysql" >> "$GITHUB_ENV"
- echo "DB_HOSTNAME=mysql" >> "$GITHUB_ENV"
- echo "ROOT_USER_EMAIL=ci@nictool.test" >> "$GITHUB_ENV"
- echo "ROOT_USER_PASSWORD=$ROOT_PW" >> "$GITHUB_ENV"
- echo "NICTOOL_CLIENT_DIR=$GITHUB_WORKSPACE/client" >> "$GITHUB_ENV"
+ echo "DB_PW=$(openssl rand -base64 24)" >> "$GITHUB_ENV"
+ echo "ROOT_PW=$(openssl rand -base64 24)" >> "$GITHUB_ENV"
- - name: Install NicTool client and server
+ - name: Create test network and start MySQL
+ run: |
+ docker network create ci
+ docker run -d --name mysql --network ci \
+ -e MYSQL_ROOT_PASSWORD=root \
+ -e MYSQL_DATABASE=nictool \
+ --health-cmd="mysqladmin ping -h 127.0.0.1" \
+ --health-interval=5s \
+ --health-timeout=3s \
+ --health-retries=10 \
+ mysql:8
+
+ - name: Start CI container
run: |
- cd "$GITHUB_WORKSPACE/client"
- perl Makefile.PL
- cpanm -n .
+ docker run -d --name ci --network ci \
+ -v "$GITHUB_WORKSPACE:/workspace" \
+ -w /workspace \
+ -e NICTOOL_DB_USER_PASSWORD="$DB_PW" \
+ -e DB_ROOT_PASSWORD=root \
+ -e NICTOOL_DB_USER=nictool \
+ -e NICTOOL_DB_NAME=nictool \
+ -e DB_ENGINE=mysql \
+ -e DB_HOSTNAME=mysql \
+ -e ROOT_USER_EMAIL=ci@nictool.test \
+ -e ROOT_USER_PASSWORD="$ROOT_PW" \
+ -e NICTOOL_CLIENT_DIR=/workspace/client \
+ nictool-ci:latest sleep infinity
+
+ - name: Wait for MySQL
+ run: |
+ for i in $(seq 1 60); do
+ if docker inspect --format='{{.State.Health.Status}}' mysql 2>/dev/null | grep -q healthy; then
+ echo "MySQL is healthy"
+ exit 0
+ fi
+ sleep 2
+ done
+ echo "MySQL failed to become healthy" && exit 1
- cd "$GITHUB_WORKSPACE/server"
- perl Makefile.PL
- cpanm -n .
+ - name: Install NicTool client and server
+ run: |
+ docker exec ci bash -c '
+ cd /workspace/client && perl Makefile.PL && cpanm -n .
+ cd /workspace/server && perl Makefile.PL && cpanm -n .
+ '
- name: Allow Apache to traverse workspace path
run: |
- dir="$GITHUB_WORKSPACE"
- while [ "$dir" != "/" ]; do
- chmod o+x "$dir"
- dir="$(dirname "$dir")"
- done
+ docker exec ci bash -c '
+ dir="/workspace"
+ while [ "$dir" != "/" ]; do
+ chmod o+x "$dir"
+ dir="$(dirname "$dir")"
+ done
+ '
- name: Set up NicTool configs, Apache, and TLS
- run: dist/setup/install-nictool.sh --nt-dir="$GITHUB_WORKSPACE"
+ run: docker exec ci dist/setup/install-nictool.sh --nt-dir=/workspace
- name: Start Apache
- run: apachectl start
+ run: docker exec ci apachectl start
- name: Create NicTool test database
- run: |
- cd "$GITHUB_WORKSPACE/server/sql"
- echo "" | perl create_tables.pl --environment
+ run: docker exec ci bash -c 'cd /workspace/server/sql && echo "" | perl create_tables.pl --environment'
- name: Create test user and test.cfg
- run: perl dist/setup/setup-test-env.pl
+ run: docker exec ci perl dist/setup/setup-test-env.pl
- name: Run client tests
- run: make -C "$GITHUB_WORKSPACE/client" test
+ run: docker exec ci make -C /workspace/client test
- name: Run server tests
- run: make -C "$GITHUB_WORKSPACE/server" test
+ run: docker exec ci make -C /workspace/server test
- name: Failure diagnostics
if: failure()
run: |
- mysql --version
- apache2 -version
- cat /var/log/apache2/error.log || true
+ docker exec ci cat /var/log/apache2/error.log 2>/dev/null || true
+ docker logs mysql 2>&1 | tail -50 || true
diff --git a/README.md b/README.md
index d61dd863..89eee61f 100644
--- a/README.md
+++ b/README.md
@@ -54,7 +54,6 @@ See [AUTHORS.md](https://github.com/nictool/NicTool/blob/master/AUTHORS.md)
### Support
* [Commercial Support](https://www.tnpi.net/cart/index.php/categories/nictool)
-* [Forums](https://www.tnpi.net/support/forums/)
* [Email](mailto:support@nictool.com) - Requests not accompanied by payments are handled on a "best effort" basis.
* [GitHub Issues](https://github.com/NicTool/NicTool/issues)
* [Wiki](https://github.com/nictool/NicTool/wiki)
diff --git a/client/htdocs/nt-webauthn.js b/client/htdocs/nt-webauthn.js
new file mode 100644
index 00000000..a142813a
--- /dev/null
+++ b/client/htdocs/nt-webauthn.js
@@ -0,0 +1,307 @@
+/**
+ * NicTool WebAuthn/Passkey support
+ *
+ * Provides browser-side WebAuthn ceremony helpers for passkey
+ * registration and authentication. ES5-compatible, uses jQuery
+ * for AJAX calls (consistent with existing NicTool JS).
+ */
+
+/* global jQuery, navigator, Uint8Array, document, window */
+
+(function ($) {
+ 'use strict';
+
+ // --- Base64URL helpers ---
+
+ function base64urlToBytes(str) {
+ // Pad to multiple of 4
+ var pad = str.length % 4;
+ if (pad === 2) str += '==';
+ else if (pad === 3) str += '=';
+ // Convert base64url to base64
+ str = str.replace(/-/g, '+').replace(/_/g, '/');
+ var raw = atob(str);
+ var bytes = new Uint8Array(raw.length);
+ for (var i = 0; i < raw.length; i++) {
+ bytes[i] = raw.charCodeAt(i);
+ }
+ return bytes.buffer;
+ }
+
+ function bytesToBase64url(buffer) {
+ var bytes = new Uint8Array(buffer);
+ var str = '';
+ for (var i = 0; i < bytes.length; i++) {
+ str += String.fromCharCode(bytes[i]);
+ }
+ return btoa(str)
+ .replace(/\+/g, '-')
+ .replace(/\//g, '_')
+ .replace(/=+$/, '');
+ }
+
+ // --- Feature detection ---
+
+ function isSupported() {
+ return !!(
+ window.PublicKeyCredential &&
+ navigator.credentials &&
+ navigator.credentials.create &&
+ navigator.credentials.get
+ );
+ }
+
+ // --- AJAX helper ---
+
+ function webauthnPost(action, payload, csrfToken) {
+ return $.ajax({
+ url: 'webauthn.cgi',
+ method: 'POST',
+ contentType: 'application/json',
+ data: JSON.stringify({
+ action: action,
+ csrf_token: csrfToken,
+ data: payload
+ }),
+ dataType: 'json'
+ });
+ }
+
+ // --- Registration ceremony ---
+
+ function register(csrfToken, ntUserId, friendlyName) {
+ if (!isSupported()) {
+ alert('Your browser does not support passkeys.');
+ return $.Deferred().reject('unsupported').promise();
+ }
+
+ var deferred = $.Deferred();
+
+ // Step 1: Get registration options from server
+ webauthnPost(
+ 'webauthn_get_registration_options',
+ { nt_user_id: ntUserId },
+ csrfToken
+ ).done(function (resp) {
+ if (resp.error_code && +resp.error_code !== 200) {
+ deferred.reject(resp.error_msg || 'Server error');
+ return;
+ }
+
+ var options = JSON.parse(resp.options);
+
+ // Convert base64url fields to ArrayBuffer
+ options.challenge = base64urlToBytes(options.challenge);
+ options.user.id = base64urlToBytes(options.user.id);
+
+ if (options.excludeCredentials) {
+ for (var i = 0; i < options.excludeCredentials.length; i++) {
+ options.excludeCredentials[i].id =
+ base64urlToBytes(options.excludeCredentials[i].id);
+ }
+ }
+
+ // Step 2: Browser ceremony
+ navigator.credentials
+ .create({ publicKey: options })
+ .then(function (credential) {
+ // Step 3: Send attestation to server
+ var response = credential.response;
+ var transports = [];
+ if (response.getTransports) {
+ transports = response.getTransports();
+ }
+
+ webauthnPost(
+ 'webauthn_verify_registration',
+ {
+ nt_user_id: ntUserId,
+ challenge_b64: bytesToBase64url(
+ options.challenge
+ ),
+ client_data_json_b64: bytesToBase64url(
+ response.clientDataJSON
+ ),
+ attestation_object_b64: bytesToBase64url(
+ response.attestationObject
+ ),
+ friendly_name:
+ friendlyName || 'Passkey',
+ transports: transports.join(',')
+ },
+ csrfToken
+ )
+ .done(function (verifyResp) {
+ if (
+ verifyResp.error_code &&
+ +verifyResp.error_code !== 200
+ ) {
+ deferred.reject(
+ verifyResp.error_msg
+ );
+ } else {
+ deferred.resolve(verifyResp);
+ }
+ })
+ .fail(function () {
+ deferred.reject(
+ 'Failed to verify registration'
+ );
+ });
+ })
+ .catch(function (err) {
+ deferred.reject(
+ err.message || 'Passkey creation cancelled'
+ );
+ });
+ }).fail(function () {
+ deferred.reject('Failed to get registration options');
+ });
+
+ return deferred.promise();
+ }
+
+ // --- Authentication ceremony ---
+
+ function authenticate(csrfToken, username) {
+ if (!isSupported()) {
+ alert('Your browser does not support passkeys.');
+ return $.Deferred().reject('unsupported').promise();
+ }
+
+ var deferred = $.Deferred();
+
+ // Step 1: Get authentication options (username optional)
+ var optionsPayload = {};
+ if (username) optionsPayload.username = username;
+
+ webauthnPost(
+ 'webauthn_get_auth_options',
+ optionsPayload,
+ csrfToken
+ ).done(function (resp) {
+ if (resp.error_code && +resp.error_code !== 200) {
+ deferred.reject(resp.error_msg || 'Server error');
+ return;
+ }
+
+ var options = JSON.parse(resp.options);
+
+ // Convert base64url fields to ArrayBuffer
+ options.challenge = base64urlToBytes(options.challenge);
+
+ if (options.allowCredentials) {
+ for (var i = 0; i < options.allowCredentials.length; i++) {
+ options.allowCredentials[i].id =
+ base64urlToBytes(options.allowCredentials[i].id);
+ }
+ }
+
+ // Step 2: Browser ceremony
+ navigator.credentials
+ .get({ publicKey: options })
+ .then(function (assertion) {
+ var response = assertion.response;
+
+ // Step 3: Send assertion to server
+ var verifyPayload = {
+ challenge_b64: bytesToBase64url(
+ options.challenge
+ ),
+ credential_id_b64: bytesToBase64url(
+ assertion.rawId
+ ),
+ client_data_json_b64: bytesToBase64url(
+ response.clientDataJSON
+ ),
+ authenticator_data_b64: bytesToBase64url(
+ response.authenticatorData
+ ),
+ signature_b64: bytesToBase64url(
+ response.signature
+ )
+ };
+ if (response.userHandle &&
+ response.userHandle.byteLength > 0) {
+ verifyPayload.user_handle_b64 =
+ bytesToBase64url(response.userHandle);
+ }
+ webauthnPost(
+ 'webauthn_verify_auth',
+ verifyPayload,
+ csrfToken
+ )
+ .done(function (verifyResp) {
+ if (
+ verifyResp.error_code &&
+ +verifyResp.error_code !== 200
+ ) {
+ deferred.reject(
+ verifyResp.error_msg
+ );
+ } else {
+ deferred.resolve(verifyResp);
+ }
+ })
+ .fail(function () {
+ deferred.reject(
+ 'Failed to verify authentication'
+ );
+ });
+ })
+ .catch(function (err) {
+ deferred.reject(
+ err.message || 'Passkey authentication cancelled'
+ );
+ });
+ }).fail(function () {
+ deferred.reject('Failed to get authentication options');
+ });
+
+ return deferred.promise();
+ }
+
+ // --- Credential management ---
+
+ function revokeCredential(csrfToken, ntUserId, credentialId) {
+ return webauthnPost(
+ 'webauthn_revoke_credential',
+ {
+ nt_user_id: ntUserId,
+ nt_webauthn_credential_id: credentialId
+ },
+ csrfToken
+ );
+ }
+
+ function renameCredential(csrfToken, ntUserId, credentialId, name) {
+ return webauthnPost(
+ 'webauthn_rename_credential',
+ {
+ nt_user_id: ntUserId,
+ nt_webauthn_credential_id: credentialId,
+ friendly_name: name
+ },
+ csrfToken
+ );
+ }
+
+ function listCredentials(csrfToken, ntUserId) {
+ return webauthnPost(
+ 'webauthn_get_user_credentials',
+ { nt_user_id: ntUserId },
+ csrfToken
+ );
+ }
+
+ // --- Public API ---
+
+ window.NtWebAuthn = {
+ isSupported: isSupported,
+ register: register,
+ authenticate: authenticate,
+ revokeCredential: revokeCredential,
+ renameCredential: renameCredential,
+ listCredentials: listCredentials
+ };
+})(jQuery);
diff --git a/client/htdocs/user.cgi b/client/htdocs/user.cgi
index 819449b1..cfc30d25 100755
--- a/client/htdocs/user.cgi
+++ b/client/htdocs/user.cgi
@@ -175,6 +175,7 @@ sub display {
];
display_properties( $nt_obj, $q, $user, $duser, $edit_message );
+ display_passkeys( $nt_obj, $q, $user, $duser );
display_global_log( $nt_obj, $q, $user, $duser, $message );
$nt_obj->parse_template($NicToolClient::end_html_template);
@@ -273,6 +274,106 @@ sub display_properties {
return $duser;
}
+sub display_passkeys {
+ my ( $nt_obj, $q, $user, $duser ) = @_;
+
+ # Only show passkeys section when viewing own profile or as admin
+ my $is_self = $user->{'nt_user_id'} eq $duser->{'nt_user_id'};
+ return unless $is_self || $user->{'user_write'};
+
+ my $uid = $duser->{'nt_user_id'};
+ my $csrf_token = $nt_obj->get_csrf_token();
+
+ print qq[
+
+
+
+];
+}
+
sub display_global_log {
my ( $nt_obj, $q, $user, $duser, $message ) = @_;
diff --git a/client/htdocs/webauthn.cgi b/client/htdocs/webauthn.cgi
new file mode 100755
index 00000000..cca85b86
--- /dev/null
+++ b/client/htdocs/webauthn.cgi
@@ -0,0 +1,137 @@
+#!/usr/bin/perl
+#
+# WebAuthn JSON API proxy for NicToolClient
+#
+# Accepts JSON POST requests, validates CSRF, forwards to
+# NicToolServer via SOAP, and returns JSON responses.
+#
+
+use strict;
+use warnings;
+
+use JSON;
+
+require 'nictoolclient.conf';
+
+main();
+
+sub main {
+ my $q = new CGI();
+ my $nt_obj = new NicToolClient($q);
+
+ return if $nt_obj->check_setup ne 'OK';
+
+ # Only accept POST with JSON content
+ if ( $q->request_method() ne 'POST' ) {
+ send_json( $q, 405, { error_code => 405, error_msg => 'Method not allowed' } );
+ return;
+ }
+
+ my $body = $q->param('POSTDATA') || $q->param('keywords') || '';
+ if ( !$body ) {
+
+ # Try reading raw stdin for JSON
+ local $/;
+ $body = if !$body;
+ }
+
+ my $request;
+ eval { $request = decode_json($body); };
+ if ( $@ || !$request ) {
+ send_json( $q, 400, { error_code => 400, error_msg => 'Invalid JSON' } );
+ return;
+ }
+
+ my $action = $request->{action} || '';
+ my $data = $request->{data} || {};
+
+ # CSRF validation
+ my $csrf_token = $request->{csrf_token} || '';
+ my $cookie_csrf = $q->cookie('NicTool_csrf') || '';
+ if ( !$csrf_token || !$cookie_csrf || $csrf_token ne $cookie_csrf ) {
+ send_json( $q, 403, { error_code => 403, error_msg => 'CSRF validation failed' } );
+ return;
+ }
+
+ # Whitelist of allowed actions — reject anything else
+ my %allowed = map { $_ => 1 } qw(
+ webauthn_get_registration_options
+ webauthn_verify_registration
+ webauthn_get_user_credentials
+ webauthn_revoke_credential
+ webauthn_rename_credential
+ webauthn_get_auth_options
+ webauthn_verify_auth
+ );
+
+ if ( !$allowed{$action} ) {
+ send_json( $q, 400, { error_code => 400, error_msg => 'Unknown action' } );
+ return;
+ }
+
+ # Pre-session actions (no session cookie required)
+ my %no_session = map { $_ => 1 } qw(
+ webauthn_get_auth_options
+ webauthn_verify_auth
+ );
+
+ my %params = ( action => $action );
+
+ if ( !$no_session{$action} ) {
+
+ # Authenticated actions need session cookie
+ my $cookie = $q->cookie('NicTool');
+ if ( !$cookie ) {
+ send_json( $q, 403, { error_code => 403, error_msg => 'Not authenticated' } );
+ return;
+ }
+ $params{nt_user_session} = $cookie;
+ }
+
+ # Merge request data into params, skipping reserved keys
+ for my $key ( keys %$data ) {
+ next if $key eq 'action' || $key eq 'nt_user_session';
+ $params{$key} = $data->{$key};
+ }
+
+ my $response = $nt_obj->{nt_server_obj}->send_request(%params);
+
+ if ( !ref $response ) {
+ send_json( $q, 500, { error_code => 500, error_msg => $response || 'Server error' } );
+ return;
+ }
+
+ # For passkey login success, set the session cookie
+ if ( $action eq 'webauthn_verify_auth'
+ && $response->{nt_user_session} )
+ {
+ my $session = $response->{nt_user_session};
+ $session =~ s/[^\x20-\x7E]//g; # strip control chars
+ my $secure = ( $ENV{HTTPS} || '' ) eq 'on' ? 1 : 0;
+ print $q->header(
+ -type => 'application/json',
+ -charset => 'utf-8',
+ -cookie => CGI::cookie(
+ -name => 'NicTool',
+ -value => $session,
+ -path => '/',
+ -httponly => 1,
+ -secure => $secure,
+ -samesite => 'Strict',
+ ),
+ );
+ print encode_json($response);
+ return;
+ }
+
+ send_json( $q, 200, $response );
+}
+
+sub send_json {
+ my ( $q, $status, $data ) = @_;
+ print $q->header(
+ -type => 'application/json',
+ -charset => 'utf-8',
+ );
+ print encode_json($data);
+}
diff --git a/client/t/01-syntax.t b/client/t/01-syntax.t
index 3cfb5121..0ac58184 100644
--- a/client/t/01-syntax.t
+++ b/client/t/01-syntax.t
@@ -2,7 +2,7 @@
use Config 'myconfig';
use Data::Dumper;
use English '-no_match_vars';
-use Test::More tests => 24;
+use Test::More tests => 25;
use lib 'lib';
diff --git a/client/t/e2e/helpers.ts b/client/t/e2e/helpers.ts
index 48f739aa..1d2d68c1 100644
--- a/client/t/e2e/helpers.ts
+++ b/client/t/e2e/helpers.ts
@@ -290,6 +290,88 @@ export async function deleteNameserver(playwright: any, cookies: string, gid: st
await authGet(playwright, `${BASE}/group_nameservers.cgi?nt_group_id=${gid}&delete=1&nt_nameserver_id=${nsid}&csrf_token=${extractCsrf(cookies)}`, cookies);
}
+// ---------------------------------------------------------------------------
+// WebAuthn Helpers
+// ---------------------------------------------------------------------------
+
+export async function webauthnPost(
+ playwright: any, action: string, data: Record,
+ csrfCookie: string, sessionCookie?: string
+): Promise<{ res: any; json: any }> {
+ const ctx = await freshCtx(playwright);
+ let cookie = `NicTool_csrf=${csrfCookie}`;
+ if (sessionCookie) cookie = `NicTool=${sessionCookie}; ${cookie}`;
+
+ const res = await ctx.post(`${BASE}/webauthn.cgi`, {
+ headers: { 'Content-Type': 'application/json', Cookie: cookie },
+ data: JSON.stringify({ action, csrf_token: csrfCookie, data }),
+ });
+
+ const text = await res.text();
+ let json: any;
+ try { json = JSON.parse(text); } catch { json = { error_code: 500, error_msg: text }; }
+ await ctx.dispose();
+ return { res, json };
+}
+
+export async function listPasskeys(
+ playwright: any, cookies: string, ntUserId: string | number
+): Promise {
+ const csrf = extractCsrf(cookies);
+ const session = cookies.match(/NicTool=([^;]+)/)?.[1] || '';
+ const { json } = await webauthnPost(
+ playwright, 'webauthn_get_user_credentials',
+ { nt_user_id: ntUserId }, csrf, session
+ );
+ return json.credentials || [];
+}
+
+export async function revokePasskey(
+ playwright: any, cookies: string,
+ ntUserId: string | number, credentialDbId: string | number
+): Promise {
+ const csrf = extractCsrf(cookies);
+ const session = cookies.match(/NicTool=([^;]+)/)?.[1] || '';
+ await webauthnPost(
+ playwright, 'webauthn_revoke_credential',
+ { nt_user_id: ntUserId, nt_webauthn_credential_id: credentialDbId },
+ csrf, session
+ );
+}
+
+export async function revokeAllPasskeys(
+ playwright: any, cookies: string, ntUserId: string | number
+): Promise {
+ const creds = await listPasskeys(playwright, cookies, ntUserId);
+ for (const c of creds) {
+ await revokePasskey(playwright, cookies, ntUserId, c.nt_webauthn_credential_id);
+ }
+}
+
+export async function setupVirtualAuthenticator(page: Page) {
+ const cdpSession = await page.context().newCDPSession(page);
+ await cdpSession.send('WebAuthn.enable');
+ const result = await cdpSession.send('WebAuthn.addVirtualAuthenticator', {
+ options: {
+ protocol: 'ctap2',
+ transport: 'internal',
+ hasResidentKey: true,
+ hasUserVerification: true,
+ isUserVerified: true,
+ },
+ });
+ return { cdpSession, authenticatorId: result.authenticatorId };
+}
+
+export async function teardownVirtualAuthenticator(
+ cdpSession: any, authenticatorId: string
+): Promise {
+ try {
+ await cdpSession.send('WebAuthn.removeVirtualAuthenticator', { authenticatorId });
+ await cdpSession.send('WebAuthn.disable');
+ } catch { /* page may already be closed */ }
+}
+
// ---------------------------------------------------------------------------
// Internal Utilities
// ---------------------------------------------------------------------------
diff --git a/client/t/e2e/security.spec.ts b/client/t/e2e/security.spec.ts
index aef46d9c..c5b5770a 100644
--- a/client/t/e2e/security.spec.ts
+++ b/client/t/e2e/security.spec.ts
@@ -199,7 +199,7 @@ test.describe('T8: XSS Protection', () => {
const res = await ctx.get(`${BASE}/index.cgi?message=`);
const body = await res.text();
expect(body).not.toContain('');
- expect(body).not.toMatch(/
+
@@ -27,6 +29,9 @@
{{CSRF_TOKEN_FIELD}}
+
@@ -38,5 +43,31 @@
+