Skip to content

fix(cloudways): exclude wildcards from Let's Encrypt SSL request, keep them in aliases #1160

@superdav42

Description

@superdav42

Summary

Cloudways_Domain_Mapping::get_valid_ssl_domains() currently includes wildcard
entries from WU_CLOUDWAYS_EXTRA_DOMAINS in the Let's Encrypt SSL request — it
strips the leading *. and passes the apex to Cloudways' /security/lets_encrypt_install
endpoint. Combined with Cloudways_Integration::send_cloudways_request()
hard-coding 'wild_card' => false, this can collide with manually-installed
wildcard certificates and prevent per-tenant custom domains from getting SSL
(see #1141 for the user report).

The integration should drop wildcard entries from the SSL request entirely
while still keeping them on the aliases list (Cloudways needs them in
aliases for routing, but they have no place in a non-wildcard Let's Encrypt
request).

Why

  • Wildcards in the SSL list are guaranteed to be wrong: the integration always
    sends wild_card => false, so requesting Let's Encrypt for an apex stripped
    from a wildcard either silently accepts a non-wildcard apex cert (not what
    the user expects) or noisily fails DNS validation when the apex doesn't
    resolve to the network IP (most multisite setups).
  • Aliases must still contain the wildcard so subsite routing on Cloudways works.
  • The doc PRs (docs(cloudways): warn against using network wildcard in WU_CLOUDWAYS_EXTRA_DOMAINS #1159, docs(cloudways): warn against using network wildcard in WU_CLOUDWAYS_EXTRA_DOMAINS docs#45) tell users not to put their
    network's wildcard in WU_CLOUDWAYS_EXTRA_DOMAINS, but that constant is
    legitimately useful for external wildcards (e.g. a wildcard on a separate
    marketing domain shared with the same Cloudways app). The code should handle
    any wildcard correctly — keep it in aliases, exclude it from the SSL request.

Files to Modify

  • EDIT: inc/integrations/providers/cloudways/class-cloudways-domain-mapping.php:197-220
    — change get_valid_ssl_domains() to filter out wildcard entries instead
    of stripping the *. prefix. The aliases path
    (get_domains() / sync_domains()) must keep wildcards untouched.
  • EDIT: tests/WP_Ultimo/Integrations/Providers/Cloudways_Domain_Mapping_Test.php
    — add regression tests covering: (a) wildcard in WU_CLOUDWAYS_EXTRA_DOMAINS
    is present in the aliases payload, (b) the same wildcard is absent from
    the ssl_domains payload, (c) plain (non-wildcard) extra domains still flow
    to both. Use the existing getMockBuilder(Cloudways_Integration::class)
    pattern from the test file (line 16-18).

How (Approach)

Current implementation (the defect)

// inc/integrations/providers/cloudways/class-cloudways-domain-mapping.php:197-220
private function get_valid_ssl_domains(array $domains): array {

    $ssl_domains = array_unique(
        array_map(
            function ($domain) {
                if (str_starts_with($domain, '*.')) {
                    $domain = str_replace('*.', '', $domain); // ← strips, retains apex
                }
                return $domain;
            },
            $domains
        )
    );

    $ssl_valid_domains = $this->check_domain_dns($ssl_domains, Helper::get_network_public_ip());
    // ...
}

Proposed fix

Filter wildcards out before DNS validation rather than stripping. Apex of a
wildcard (reddomain.com from *.reddomain.com) is not the same domain
the user intended to certify, and we have no signal that DNS for the apex
resolves to the Cloudways IP.

private function get_valid_ssl_domains(array $domains): array {

    // Drop wildcard entries entirely — Cloudways' Let's Encrypt endpoint here
    // never issues wildcard certs (wild_card => false), so wildcards in the
    // SSL list are meaningless. They must still appear on the aliases list,
    // which is unaffected by this method.
    $ssl_domains = array_values(
        array_filter(
            $domains,
            static fn ($domain) => ! str_starts_with((string) $domain, '*.')
        )
    );

    $ssl_valid_domains = $this->check_domain_dns($ssl_domains, Helper::get_network_public_ip());

    $main_domain = get_current_site()->domain;

    $ssl_valid_domains[] = $main_domain;

    return array_values(array_unique(array_filter($ssl_valid_domains)));
}

get_domains() (used for the aliases call) is unchanged — wildcards continue to
flow into sync_domains()/app/manage/aliases as before.

Verification

# Lint and stan the changed file:
vendor/bin/phpcs inc/integrations/providers/cloudways/class-cloudways-domain-mapping.php
vendor/bin/phpstan analyse inc/integrations/providers/cloudways/class-cloudways-domain-mapping.php

# Run the cloudways test class:
vendor/bin/phpunit --filter Cloudways_Domain_Mapping_Test

The new tests should assert the SSL payload (captured via the existing
send_cloudways_request mock) contains the right shape for both
/app/manage/aliases and /security/lets_encrypt_install calls when
WU_CLOUDWAYS_EXTRA_DOMAINS includes a wildcard like *.example.com.

Acceptance Criteria

  • get_valid_ssl_domains() no longer emits the apex of any *.x entry into the SSL request.
  • Wildcards remain in the aliases payload (/app/manage/aliases) — sync_domains() behaviour unchanged.
  • New PHPUnit cases in Cloudways_Domain_Mapping_Test cover the aliases-yes / SSL-no split for a wildcard extra-domain.
  • vendor/bin/phpcs clean on the modified file.
  • vendor/bin/phpstan analyse clean on the modified file.
  • vendor/bin/phpunit --filter Cloudways_Domain_Mapping_Test green.

Context

Once merged, the wildcard SSL pitfall section in the docs can be softened
(it will still be best-practice to use a standard cert on the network domain,
but WU_CLOUDWAYS_EXTRA_DOMAINS will tolerate wildcards safely for genuinely
external use cases).

Ref #1141


aidevops.sh v3.14.93 plugin for OpenCode v1.14.41 with gemma4:e4b spent 16h and 263,673 tokens on this with the user in an interactive session.

Metadata

Metadata

Assignees

Labels

bugSomething isn't workingorigin:interactiveCreated by interactive user sessionpriority:criticalCritical severity — security or data loss risksolved:interactiveTask was solved by an interactive sessionstatus:queuedWorker dispatched, not yet startedtier:standardAuto-created by pulse labelless backfill (t2112)

Type

No type
No fields configured for issues without a type.

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions