fix: accept underscores in domain hostnames for API URL validation (#10663)
This commit is contained in:
commit
f3ca349765
9 changed files with 99 additions and 11 deletions
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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 = [];
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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':
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
29
tests/Unit/IsValidDomainUrlTest.php
Normal file
29
tests/Unit/IsValidDomainUrlTest.php
Normal 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();
|
||||||
|
});
|
||||||
|
|
@ -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();
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue