fix: accept underscores in domain hostnames for API URL validation (#10663)

This commit is contained in:
Andras Bacsai 2026-07-07 12:27:29 +02:00 committed by GitHub
commit f3ca349765
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 99 additions and 11 deletions

View file

@ -1091,7 +1091,7 @@ private function create_application(Request $request, $type)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -1332,7 +1332,7 @@ private function create_application(Request $request, $type)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -1545,7 +1545,7 @@ private function create_application(Request $request, $type)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;
@ -2551,7 +2551,7 @@ public function update_by_uuid(Request $request)
$errors = []; $errors = [];
$urls = $urls->map(function ($url) use (&$errors) { $urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
return $url; return $url;

View file

@ -557,7 +557,7 @@ public static function validateApplicationDomains(mixed $value): array
continue; continue;
} }
if (! filter_var($url, FILTER_VALIDATE_URL)) { if (! isValidDomainUrl($url)) {
$errors[] = "Invalid URL: {$url}"; $errors[] = "Invalid URL: {$url}";
continue; continue;

View file

@ -4,6 +4,54 @@
use App\Models\ServiceApplication; use App\Models\ServiceApplication;
use Illuminate\Support\Collection; use Illuminate\Support\Collection;
function isValidDomainUrl(string $url): bool
{
$components = parse_url($url);
if ($components === false) {
return false;
}
$scheme = $components['scheme'] ?? '';
$host = $components['host'] ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true) || $host === '') {
return false;
}
$urlToValidate = $scheme.'://';
if (isset($components['user'])) {
$urlToValidate .= $components['user'];
if (isset($components['pass'])) {
$urlToValidate .= ':'.$components['pass'];
}
$urlToValidate .= '@';
}
$urlToValidate .= str_replace('_', '-', $host);
if (isset($components['port'])) {
$urlToValidate .= ':'.$components['port'];
}
if (isset($components['path'])) {
$urlToValidate .= $components['path'];
}
if (isset($components['query'])) {
$urlToValidate .= '?'.$components['query'];
}
if (isset($components['fragment'])) {
$urlToValidate .= '#'.$components['fragment'];
}
return filter_var($urlToValidate, FILTER_VALIDATE_URL) !== false;
}
function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null) function checkDomainUsage(ServiceApplication|Application|null $resource = null, ?string $domain = null)
{ {
$conflicts = []; $conflicts = [];

View file

@ -2553,6 +2553,10 @@
"is_preserve_repository_enabled": { "is_preserve_repository_enabled": {
"type": "boolean", "type": "boolean",
"description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false." "description": "Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false."
},
"include_source_commit_in_build": {
"type": "boolean",
"description": "Include source commit information in the build. Default is false."
} }
}, },
"type": "object" "type": "object"

View file

@ -1663,6 +1663,9 @@ paths:
is_preserve_repository_enabled: is_preserve_repository_enabled:
type: boolean type: boolean
description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.' description: 'Preserve git repository during application update. If false, the existing repository will be removed and replaced with the new one. If true, the existing repository will be kept and the new one will be ignored. Default is false.'
include_source_commit_in_build:
type: boolean
description: 'Include source commit information in the build. Default is false.'
type: object type: object
responses: responses:
'200': '200':

View file

@ -803,7 +803,7 @@
"category": "backend", "category": "backend",
"logo": "svgs/convex.svg", "logo": "svgs/convex.svg",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-06-12T10:45:52+02:00", "template_last_updated_at": "2026-05-09T19:26:30+05:30",
"port": "6791" "port": "6791"
}, },
"cryptgeon": { "cryptgeon": {
@ -1779,7 +1779,7 @@
"category": "devtools", "category": "devtools",
"logo": "svgs/gitea.svg", "logo": "svgs/gitea.svg",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-06-06T00:11:24+02:00" "template_last_updated_at": "2026-06-01T07:54:27-05:00"
}, },
"gitea-with-mariadb": { "gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io", "documentation": "https://docs.gitea.com?utm_source=coolify.io",
@ -2361,7 +2361,7 @@
"category": "automation", "category": "automation",
"logo": "svgs/inngest.png", "logo": "svgs/inngest.png",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-07-02T13:25:47+02:00", "template_last_updated_at": null,
"port": "8288" "port": "8288"
}, },
"invoice-ninja": { "invoice-ninja": {

View file

@ -803,7 +803,7 @@
"category": "backend", "category": "backend",
"logo": "svgs/convex.svg", "logo": "svgs/convex.svg",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-06-12T10:45:52+02:00", "template_last_updated_at": "2026-05-09T19:26:30+05:30",
"port": "6791" "port": "6791"
}, },
"cryptgeon": { "cryptgeon": {
@ -1779,7 +1779,7 @@
"category": "devtools", "category": "devtools",
"logo": "svgs/gitea.svg", "logo": "svgs/gitea.svg",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-06-06T00:11:24+02:00" "template_last_updated_at": "2026-06-01T07:54:27-05:00"
}, },
"gitea-with-mariadb": { "gitea-with-mariadb": {
"documentation": "https://docs.gitea.com?utm_source=coolify.io", "documentation": "https://docs.gitea.com?utm_source=coolify.io",
@ -2361,7 +2361,7 @@
"category": "automation", "category": "automation",
"logo": "svgs/inngest.png", "logo": "svgs/inngest.png",
"minversion": "0.0.0", "minversion": "0.0.0",
"template_last_updated_at": "2026-07-02T13:25:47+02:00", "template_last_updated_at": null,
"port": "8288" "port": "8288"
}, },
"invoice-ninja": { "invoice-ninja": {

View file

@ -0,0 +1,29 @@
<?php
it('accepts hostnames containing underscores', function () {
// Regression: PHP's FILTER_VALIDATE_URL rejects underscores in the host,
// which blocked valid service domains (e.g. Docker service naming) from
// being saved and getting Let's Encrypt certificates. See issue #10597.
expect(isValidDomainUrl('https://myapp_service.example.com'))->toBeTrue();
expect(isValidDomainUrl('http://my_app.example.com'))->toBeTrue();
expect(isValidDomainUrl('https://a_b_c.example.com/path'))->toBeTrue();
});
it('accepts ordinary domains and URLs', function () {
expect(isValidDomainUrl('https://example.com'))->toBeTrue();
expect(isValidDomainUrl('http://sub.example.com:8080/path?q=1'))->toBeTrue();
expect(isValidDomainUrl('https://example.com/a_b'))->toBeTrue();
});
it('rejects strings that are not valid URLs', function () {
expect(isValidDomainUrl('not a url'))->toBeFalse();
expect(isValidDomainUrl('example.com'))->toBeFalse();
expect(isValidDomainUrl('ht_tp://example.com'))->toBeFalse();
expect(isValidDomainUrl(''))->toBeFalse();
});
it('rejects URLs that do not use HTTP or HTTPS schemes', function () {
expect(isValidDomainUrl('ftp://example.com'))->toBeFalse();
expect(isValidDomainUrl('javascript://example.com'))->toBeFalse();
expect(isValidDomainUrl('data://example.com'))->toBeFalse();
});

View file

@ -187,3 +187,7 @@
expect(ValidationPatterns::normalizeApplicationDomains($domains)) expect(ValidationPatterns::normalizeApplicationDomains($domains))
->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1'); ->toBe('https://example.com/MixedCase/Path?Token=ABC#Fragment,http://sub.example.com/Api/V1');
}); });
it('validates application domains with underscores in the hostname', function () {
expect(ValidationPatterns::validateApplicationDomains('https://myapp_service.example.com'))->toBeEmpty();
});