Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
01be4ba
feat(network): let public address validators accept an allowlist
abnegate Oct 1, 2026
576790f
fix(migrations): validate source endpoints
abnegate Oct 1, 2026
ff22697
refactor(network): move subnet and allowlist validators into utopia-p…
abnegate Oct 1, 2026
70a567a
test(migrations): cover stored endpoint revalidation end to end
abnegate Oct 1, 2026
9201b04
fix(migrations): pin appwrite source and report requests to the valid…
abnegate Oct 2, 2026
5fbde29
fix(network): keep blocking lookups in coroutines without network hooks
abnegate Oct 2, 2026
b07a1b8
test(migrations): cover an endpoint admitted by an allowed subnet end…
abnegate Oct 2, 2026
b0eafd9
Merge branch 'main' into fix/migration-endpoint-ssrf
abnegate Oct 2, 2026
294dfd3
test(migrations): check resolve entries with the Subnet validator
abnegate Oct 2, 2026
0c4cb9a
test(migrations): reach the allowed subnet through a platform hostname
abnegate Oct 2, 2026
b72632a
fix(migrations): check appwrite source endpoints against the destinat…
abnegate Oct 2, 2026
4d77768
chore(migrations): rebuild on the destination policy from main
abnegate Oct 2, 2026
94b04c1
fix(client): treat ipv4-compatible and site-local ipv6 as reserved
abnegate Oct 2, 2026
07358a9
fix(migrations): accept _APP_ALLOWED_INTERNAL_ADDRESSES for appwrite …
abnegate Oct 2, 2026
e3ba3f3
fix(migrations): keep hostnames out of _APP_ALLOWED_INTERNAL_ADDRESSES
abnegate Oct 2, 2026
2928c36
Merge main into fix/migration-endpoint-ssrf
abnegate Oct 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ _APP_LOGGING_FORMAT=pretty
_APP_MIGRATION_HOST=appwrite
_APP_MIGRATIONS_FIREBASE_CLIENT_ID=
_APP_MIGRATIONS_FIREBASE_CLIENT_SECRET=
_APP_MIGRATIONS_ALLOWED_HOSTS=appwrite,172.16.238.0/24
_APP_PROJECT_REGIONS=default
_APP_GEO_SECRET=your-secret-key
_APP_GEO_ENDPOINT=http://appwrite-geo/v1
Expand Down
9 changes: 9 additions & 0 deletions app/config/variables.php
Original file line number Diff line number Diff line change
Expand Up @@ -1998,6 +1998,15 @@
'required' => false,
'question' => '',
'filter' => ''
],
[
'name' => '_APP_MIGRATIONS_ALLOWED_HOSTS',
'description' => 'Comma-separated hostnames, IP addresses and CIDR ranges (IPv4 or IPv6, for example 10.0.0.0/8 or fd00::/8) that Appwrite migration source endpoints may use even when they are not public. Hostnames match exactly, ignoring case and a trailing dot, with no wildcard or suffix matching. Ranges match an IP endpoint and every address a hostname resolves to. Entries that cannot be parsed are ignored. Empty by default, which allows public sources only.',
'introduction' => '2.3.1',
'default' => '',
'required' => false,
'question' => '',
'filter' => ''
]
]
],
Expand Down
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@
"appwrite/php-clamav": "2.0.*",
"utopia-php/config": "1.*",
"utopia-php/database": "^7.4.0",
"utopia-php/migration": "^2.0.0",
"utopia-php/migration": "dev-feat/source-request-controls-2.0.x as 2.0.9",
"mustangostang/spyc": "0.6.*",
"dragonmantank/cron-expression": "3.4.*",
"chillerlan/php-qrcode": "4.3.*",
Expand Down
27 changes: 18 additions & 9 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 3 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,7 @@ services:
- _APP_VCS_WEBHOOK_URL
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
- _APP_MIGRATIONS_ALLOWED_HOSTS
- _APP_ASSISTANT_OPENAI_API_KEY
- _APP_CONSOLE_COUNTRIES_DENYLIST
- _APP_DATABASE_SHARED_TABLES
Expand Down Expand Up @@ -587,6 +588,7 @@ services:
- _APP_SMS_PROJECTS_DENY_LIST
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
- _APP_MIGRATIONS_ALLOWED_HOSTS
- _APP_MIGRATION_HOST
- _APP_MAINTENANCE_RETENTION_AUDIT
- _APP_MAINTENANCE_RETENTION_AUDIT_CONSOLE
Expand Down Expand Up @@ -1442,6 +1444,7 @@ services:
- _APP_LOGGING_FORMAT
- _APP_MIGRATIONS_FIREBASE_CLIENT_ID
- _APP_MIGRATIONS_FIREBASE_CLIENT_SECRET
- _APP_MIGRATIONS_ALLOWED_HOSTS
- _APP_DATABASE_SHARED_TABLES
- _APP_OPTIONS_FORCE_HTTPS
- _APP_MIGRATION_HOST
Expand Down
16 changes: 16 additions & 0 deletions packages/validators/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,22 @@

All notable changes to `utopia-php/validators` are documented in this file.

## Unreleased

### Added

- New `Subnet` validator. Accepts an IPv4 or IPv6 address inside a CIDR range
(`10.0.0.0/8`, `fd00::/8`), or equal to a single address when the range has
no prefix length. Host bits in the range are masked, and an address of the
other family never matches, so `::ffff:10.0.0.5` is outside `10.0.0.0/8`.
The constructor throws `InvalidArgumentException` for a range that is not an
IP address with an optional decimal prefix length.
- New `Allowlist` validator. Accepts a listed hostname, compared exactly after
lowercasing and removing one trailing dot (no wildcard or suffix matching),
or an IP address (optionally in brackets) inside one of the listed `Subnet`s.
`hasHostname()`, `hasAddress()` and `hasSubnets()` expose each half for
callers that check names and resolved addresses separately.

## 0.4.2

### Added
Expand Down
2 changes: 2 additions & 0 deletions packages/validators/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,8 @@ For advanced flows combine validators with `Multiple`, `AnyOf`, `AllOf`, `NoneOf
- `ArrayList`, `Assoc`, `Nullable`, `WhiteList`, `Wildcard`
- `Boolean`, `Integer`, `FloatValidator`, `Numeric`, `Range`
- `Domain`, `Host`, `Hostname`, `IP`, `URL`
- `Subnet` – an IPv4 or IPv6 address inside a CIDR range, or equal to a single address
- `Allowlist` – a hostname listed exactly (case and a trailing dot ignored), or an address inside a listed `Subnet`
- `HexColor`, `Identifier`, `JSON`, `Phone`, `Text`
- `JSON\ObjectValidator`, `JSON\ArrayValidator` – JSON shape checks that accept encoded strings
- `JSON\FCM` – FCM service account JSON with required credential fields
Expand Down
78 changes: 78 additions & 0 deletions packages/validators/src/Validator/Allowlist.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace Utopia\Validator;

use Utopia\Validator;

/**
* Validates that a value is a listed hostname, or an IP address inside one of
* the listed subnets. Hostnames match exactly, ignoring case and a trailing
* dot, with no wildcard or suffix matching. A listed hostname never admits an
* address, and a subnet never admits a hostname.
*/
class Allowlist extends Validator
{
/**
* @var array<string>
*/
private readonly array $hostnames;

/**
* @param array<string> $hostnames
* @param array<Subnet> $subnets
*/
public function __construct(
array $hostnames = [],
private readonly array $subnets = [],
) {
$this->hostnames = \array_values(\array_unique(\array_map($this->normalize(...), $hostnames)));
}

public function getDescription(): string
{
return 'Value must be an allowed hostname or an IP address in an allowed subnet.';
}

public function isArray(): bool
{
return false;
}

public function getType(): string
{
return self::TYPE_STRING;
}

public function isValid(mixed $value): bool
{
if (!\is_string($value)) {
return false;
}

return $this->hasHostname($value) || $this->hasAddress(\trim($value, '[]'));
}

public function hasSubnets(): bool
{
return $this->subnets !== [];
}

public function hasHostname(string $hostname): bool
{
return \in_array($this->normalize($hostname), $this->hostnames, true);
}

public function hasAddress(string $address): bool
{
return \array_any($this->subnets, fn (Subnet $subnet): bool => $subnet->isValid($address));
}

private function normalize(string $hostname): string
{
$hostname = \strtolower($hostname);

return \str_ends_with($hostname, '.') ? \substr($hostname, 0, -1) : $hostname;
}
}
101 changes: 101 additions & 0 deletions packages/validators/src/Validator/Subnet.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
<?php

declare(strict_types=1);

namespace Utopia\Validator;

use InvalidArgumentException;
use Utopia\Validator;

/**
* Validates that a value is an IPv4 or IPv6 address inside a CIDR range, or
* equal to a single address when the range has no prefix length. Addresses of
* the other family never match, so an IPv4-mapped IPv6 address is outside every
* IPv4 range.
*/
class Subnet extends Validator
{
private readonly string $network;

private readonly int $prefix;

/**
* @param string $range CIDR range (10.0.0.0/8, fd00::/8) or a single address (10.0.0.5, fd12::1)
*
* @throws InvalidArgumentException When the range is not an IP address with an optional prefix length
*/
public function __construct(private readonly string $range)
{
$parts = \explode('/', $range, 2);
$binary = $this->toBinary($parts[0]);

if ($binary === null) {
throw new InvalidArgumentException("Invalid subnet: {$range}");
}

$bits = \strlen($binary) * 8;
$length = $parts[1] ?? (string) $bits;

if (!\ctype_digit($length) || (int) $length > $bits) {
throw new InvalidArgumentException("Invalid subnet: {$range}");
}

$this->prefix = (int) $length;
$this->network = $this->mask($binary, $this->prefix);
}

public function getDescription(): string
{
return "Value must be an IP address in {$this->range}.";
}

public function isArray(): bool
{
return false;
}

public function getType(): string
{
return self::TYPE_STRING;
}

public function isValid(mixed $value): bool
{
if (!\is_string($value)) {
return false;
}

$binary = $this->toBinary($value);

if ($binary === null || \strlen($binary) !== \strlen($this->network)) {
return false;
}

return $this->mask($binary, $this->prefix) === $this->network;
}

private function toBinary(string $address): ?string
{
if (\filter_var($address, FILTER_VALIDATE_IP) === false) {
return null;
}

$binary = \inet_pton($address);

return $binary === false ? null : $binary;
}

private function mask(string $binary, int $prefix): string
{
$bytes = \intdiv($prefix, 8);
$remainder = $prefix % 8;
$masked = \substr($binary, 0, $bytes);

if ($remainder > 0) {
$masked .= \chr(\ord($binary[$bytes]) & (0xFF << (8 - $remainder)) & 0xFF);
$bytes++;
}

return $masked . \str_repeat("\0", \strlen($binary) - $bytes);
}
}
Loading
Loading