From 66b6f6ac70a4a49c0a7b94d06503a7dbf1b5bb72 Mon Sep 17 00:00:00 2001 From: thekevinm Date: Fri, 23 Jan 2026 00:12:00 +0000 Subject: [PATCH 1/3] Add Overview section with platform description Added standard overview describing DreamFactory as a secure, self-hosted enterprise data access platform for enterprise apps and on-prem LLMs. Co-Authored-By: Claude Opus 4.5 --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index 74619b3..97f7a35 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,11 @@ > **Note:** This repository contains the system code of the DreamFactory platform. If you want the full DreamFactory platform, visit the main [DreamFactory repository](https://github.com/dreamfactorysoftware/dreamfactory). + +## Overview + +DreamFactory is a secure, self-hosted enterprise data access platform that provides governed API access to any data source, connecting enterprise applications and on-prem LLMs with role-based access and identity passthrough. + ## Documentation Documentation for the platform can be found on the [DreamFactory wiki](http://wiki.dreamfactory.com). From d30da9c576abf403183f93b76420b7d6293c8190 Mon Sep 17 00:00:00 2001 From: Nic Davidson Date: Fri, 3 Apr 2026 14:53:17 -0600 Subject: [PATCH 2/3] security: add SSRF validation to import_url endpoints (Package, Import, App) --- src/Components/SsrfValidator.php | 224 ++++++++++++++++ src/Resources/App.php | 2 + src/Resources/Import.php | 7 +- src/Resources/Package.php | 7 +- tests/Security/SsrfValidationTest.php | 355 ++++++++++++++++++++++++++ 5 files changed, 593 insertions(+), 2 deletions(-) create mode 100644 src/Components/SsrfValidator.php create mode 100644 tests/Security/SsrfValidationTest.php diff --git a/src/Components/SsrfValidator.php b/src/Components/SsrfValidator.php new file mode 100644 index 0000000..1564486 --- /dev/null +++ b/src/Components/SsrfValidator.php @@ -0,0 +1,224 @@ +importAppFromPackage($storageServiceId, $storageContainer); } elseif (!empty($importUrl)) { + SsrfValidator::validateExternalUrl($importUrl); $package = new Packager($importUrl); $results = $package->importAppFromPackage($storageServiceId, $storageContainer); } else { diff --git a/src/Resources/Import.php b/src/Resources/Import.php index fd3a42b..3bd2257 100644 --- a/src/Resources/Import.php +++ b/src/Resources/Import.php @@ -4,6 +4,7 @@ use DreamFactory\Core\Components\ResourceImport\Manager; use DreamFactory\Core\Exceptions\BadRequestException; +use DreamFactory\Core\System\Components\SsrfValidator; class Import extends BaseSystemResource { @@ -22,7 +23,11 @@ protected function handlePOST() $resource = $this->request->input('resource'); if (empty($file)) { - $file = $this->request->input('import_url'); + $url = $this->request->input('import_url'); + if (!empty($url)) { + SsrfValidator::validateExternalUrl($url); + } + $file = $url; } if (empty($file)) { diff --git a/src/Resources/Package.php b/src/Resources/Package.php index 74f5bda..2538af8 100644 --- a/src/Resources/Package.php +++ b/src/Resources/Package.php @@ -5,6 +5,7 @@ use DreamFactory\Core\Components\Package\Exporter; use DreamFactory\Core\Components\Package\Importer; use DreamFactory\Core\Contracts\ServiceResponseInterface; +use DreamFactory\Core\System\Components\SsrfValidator; use DreamFactory\Core\Utility\Packager; use DreamFactory\Core\Utility\ResponseFactory; use DreamFactory\Core\Enums\ApiOptions; @@ -30,7 +31,11 @@ protected function handlePOST() // Get file from a url if (empty($file)) { - $file = $this->request->input('import_url'); + $url = $this->request->input('import_url'); + if (!empty($url)) { + SsrfValidator::validateExternalUrl($url); + } + $file = $url; } if (!empty($file)) { diff --git a/tests/Security/SsrfValidationTest.php b/tests/Security/SsrfValidationTest.php new file mode 100644 index 0000000..b6ca893 --- /dev/null +++ b/tests/Security/SsrfValidationTest.php @@ -0,0 +1,355 @@ +fail("Expected BadRequestException for URL: $url"); + } catch (BadRequestException $e) { + $this->assertInstanceOf(BadRequestException::class, $e); + if ($messageFragment !== '') { + $this->assertStringContainsStringIgnoringCase( + $messageFragment, + $e->getMessage(), + "Exception message did not contain \"$messageFragment\" for URL: $url" + ); + } + } + } + + // ------------------------------------------------------------------------- + // Valid URLs — should pass without exception + // ------------------------------------------------------------------------- + + public function testValidHttpsUrlPasses(): void + { + // We cannot make real DNS calls in unit tests, so we test the SSRF + // validator logic via a test double that skips actual DNS resolution. + // For the public-URL happy path we validate that the method returns + // the original URL string (no exception thrown) when given a clearly + // external address. + // + // Because gethostbyname() would fail in an offline CI environment we + // test by calling the IP-check helper directly with a known-good IP + // rather than end-to-end through validateExternalUrl(). + $this->assertFalse( + SsrfValidator::isPrivateOrReservedIp('93.184.216.34'), // example.com + '93.184.216.34 (example.com) should not be flagged as private' + ); + $this->assertFalse( + SsrfValidator::isPrivateOrReservedIp('8.8.8.8'), + '8.8.8.8 (Google DNS) should not be flagged as private' + ); + $this->assertFalse( + SsrfValidator::isPrivateOrReservedIp('1.1.1.1'), + '1.1.1.1 (Cloudflare DNS) should not be flagged as private' + ); + } + + public function testValidHttpUrlPasses(): void + { + // Same rationale as testValidHttpsUrlPasses — verify the IP is clean. + $this->assertFalse( + SsrfValidator::isPrivateOrReservedIp('151.101.1.140'), // fastly CDN range + 'Public CDN IP should not be flagged as private' + ); + } + + // ------------------------------------------------------------------------- + // Scheme validation + // ------------------------------------------------------------------------- + + public function testFileSchemeIsRejected(): void + { + $this->assertUrlBlocked('file:///etc/passwd', 'scheme'); + $this->assertUrlBlocked('file:///etc/shadow', 'scheme'); + $this->assertUrlBlocked('file://localhost/etc/hosts', 'scheme'); + } + + public function testFtpSchemeIsRejected(): void + { + $this->assertUrlBlocked('ftp://example.com/package.zip', 'scheme'); + } + + public function testGopherSchemeIsRejected(): void + { + $this->assertUrlBlocked('gopher://example.com/', 'scheme'); + } + + public function testDictSchemeIsRejected(): void + { + $this->assertUrlBlocked('dict://example.com/', 'scheme'); + } + + public function testLdapSchemeIsRejected(): void + { + $this->assertUrlBlocked('ldap://internal.corp/dc=example,dc=com', 'scheme'); + } + + public function testSftpSchemeIsRejected(): void + { + $this->assertUrlBlocked('sftp://example.com/package.zip', 'scheme'); + } + + public function testJavascriptSchemeIsRejected(): void + { + $this->assertUrlBlocked('javascript:alert(1)', 'scheme'); + } + + public function testDataSchemeIsRejected(): void + { + $this->assertUrlBlocked('data:text/plain,hello', 'scheme'); + } + + // ------------------------------------------------------------------------- + // Private/reserved IPv4 ranges + // ------------------------------------------------------------------------- + + public function testLoopbackIpv4IsRejected(): void + { + // 127.0.0.0/8 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('127.0.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('127.255.255.255')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('127.0.0.2')); + } + + public function testLinkLocalIsRejected(): void + { + // 169.254.0.0/16 — cloud metadata endpoints live here + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('169.254.169.254')); // AWS/GCP metadata + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('169.254.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('169.254.255.255')); + } + + public function testAwsMetadataAddressIsRejected(): void + { + // Explicit test for the canonical AWS IMDS address. + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('169.254.169.254')); + } + + public function testRfc1918TenBlockIsRejected(): void + { + // 10.0.0.0/8 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('10.0.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('10.255.255.255')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('10.1.2.3')); + } + + public function testRfc1918OneSevenTwoBlockIsRejected(): void + { + // 172.16.0.0/12 covers 172.16.x.x through 172.31.x.x + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('172.16.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('172.31.255.255')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('172.20.0.1')); + // 172.15.x and 172.32.x are public + $this->assertFalse(SsrfValidator::isPrivateOrReservedIp('172.15.255.255')); + $this->assertFalse(SsrfValidator::isPrivateOrReservedIp('172.32.0.1')); + } + + public function testRfc1918OneNineeTwoBlockIsRejected(): void + { + // 192.168.0.0/16 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('192.168.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('192.168.1.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('192.168.255.255')); + } + + public function testCgnatRangeIsRejected(): void + { + // 100.64.0.0/10 — carrier-grade NAT + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('100.64.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('100.127.255.255')); + } + + public function testMulticastRangeIsRejected(): void + { + // 224.0.0.0/4 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('224.0.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('239.255.255.255')); + } + + public function testReservedRangeIsRejected(): void + { + // 240.0.0.0/4 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('240.0.0.1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('255.255.255.254')); + } + + public function testBroadcastAddressIsRejected(): void + { + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('255.255.255.255')); + } + + // ------------------------------------------------------------------------- + // Literal IP in URL (scheme validation + IP check together) + // ------------------------------------------------------------------------- + + public function testHttpUrlWithPrivateIpIsRejected(): void + { + $this->assertUrlBlocked('http://10.0.0.1/package.zip', 'private or reserved'); + $this->assertUrlBlocked('http://192.168.1.1/package.zip', 'private or reserved'); + $this->assertUrlBlocked('http://172.16.0.1/package.zip', 'private or reserved'); + $this->assertUrlBlocked('http://127.0.0.1/package.zip', 'private or reserved'); + $this->assertUrlBlocked('http://169.254.169.254/latest/meta-data/', 'private or reserved'); + } + + public function testHttpsUrlWithPrivateIpIsRejected(): void + { + $this->assertUrlBlocked('https://10.0.0.1/package.zip', 'private or reserved'); + $this->assertUrlBlocked('https://192.168.0.100/package.zip', 'private or reserved'); + } + + // ------------------------------------------------------------------------- + // Localhost name variants + // ------------------------------------------------------------------------- + + public function testLocalhostNameIsRejected(): void + { + // gethostbyname('localhost') returns 127.0.0.1 on any sane system. + $this->assertUrlBlocked('http://localhost/package.zip', 'private or reserved'); + $this->assertUrlBlocked('https://localhost/package.zip', 'private or reserved'); + } + + public function testLocalhostWithPortIsRejected(): void + { + $this->assertUrlBlocked('http://localhost:8080/package.zip', 'private or reserved'); + } + + // ------------------------------------------------------------------------- + // IPv6 addresses + // ------------------------------------------------------------------------- + + public function testIpv6LoopbackIsRejected(): void + { + // ::1 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('::1')); + } + + public function testIpv6UnspecifiedIsRejected(): void + { + // :: + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('::')); + } + + public function testIpv6LinkLocalIsRejected(): void + { + // fe80::/10 + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('fe80::1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('fe80::dead:beef')); + } + + public function testIpv6UniqueLocalIsRejected(): void + { + // fc00::/7 covers fc00:: and fd00:: + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('fc00::1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('fd00::1')); + $this->assertTrue(SsrfValidator::isPrivateOrReservedIp('fdff::ffff')); + } + + public function testIpv6LoopbackInUrlIsRejected(): void + { + $this->assertUrlBlocked('http://[::1]/package.zip'); + } + + // ------------------------------------------------------------------------- + // Malformed / missing URL components + // ------------------------------------------------------------------------- + + public function testEmptyStringIsRejected(): void + { + $this->assertUrlBlocked('', 'could not be parsed'); + } + + public function testUrlWithNoSchemeIsRejected(): void + { + $this->assertUrlBlocked('//example.com/package.zip', 'scheme'); + } + + public function testUrlWithNoHostIsRejected(): void + { + $this->assertUrlBlocked('https:///package.zip', 'could not be parsed'); + } + + // ------------------------------------------------------------------------- + // DNS rebinding protection — verify resolved IP is checked, not just host + // ------------------------------------------------------------------------- + + /** + * If a hostname resolves to a private IP the request must be blocked, + * regardless of how the hostname looks. We test this by reaching into + * isPrivateOrReservedIp() with an IP that would be returned by a + * rebinding attack. + * + * Full end-to-end testing requires mocking DNS, which is beyond the scope + * of pure unit tests without a mocking framework; the behaviour is covered + * by the integration between resolveHost() and isPrivateOrReservedIp() + * inside validateExternalUrl(), and is verified here at the component level. + */ + public function testDnsRebindingProtection(): void + { + // Simulate: attacker-controlled DNS returns 169.254.169.254 for a + // seemingly benign hostname. + $resolvedIp = '169.254.169.254'; + $this->assertTrue( + SsrfValidator::isPrivateOrReservedIp($resolvedIp), + 'IP returned by a rebinding attack (169.254.169.254) must be blocked' + ); + + $resolvedIp = '10.0.0.1'; + $this->assertTrue( + SsrfValidator::isPrivateOrReservedIp($resolvedIp), + 'IP returned by a rebinding attack (10.0.0.1) must be blocked' + ); + } + + // ------------------------------------------------------------------------- + // Valid external IPs — should NOT be blocked + // ------------------------------------------------------------------------- + + public function testPublicIpsAreNotBlocked(): void + { + $publicIps = [ + '8.8.8.8', // Google DNS + '1.1.1.1', // Cloudflare DNS + '93.184.216.34', // example.com + '104.16.0.0', // Cloudflare CDN + '151.101.1.140', // Fastly + '52.84.0.0', // AWS CloudFront (public range) + ]; + + foreach ($publicIps as $ip) { + $this->assertFalse( + SsrfValidator::isPrivateOrReservedIp($ip), + "Public IP $ip should not be flagged as private/reserved" + ); + } + } +} From 5185ba711f76d0b83fb8dc812d56e66989142189 Mon Sep 17 00:00:00 2001 From: Nic Davidson Date: Fri, 10 Apr 2026 11:05:35 -0600 Subject: [PATCH 3/3] Security: remove admin flag from password reset email URL The password reset link included &admin=1 for system administrators, disclosing admin status in emails, browser history, and URL logs. The frontend can determine admin status after reset via session info. --- src/Resources/UserPasswordResource.php | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/Resources/UserPasswordResource.php b/src/Resources/UserPasswordResource.php index 36aee47..5e2c7ae 100644 --- a/src/Resources/UserPasswordResource.php +++ b/src/Resources/UserPasswordResource.php @@ -468,8 +468,7 @@ protected function sendPasswordResetEmail(User $user) $data['link'] = url(\Config::get('df.confirm_reset_url')) . '?code=' . $user->confirm_code . '&email=' . $email . - '&username=' . strip_tags($user->username) . - '&admin=' . $user->is_sys_admin; + '&username=' . strip_tags($user->username); $data['confirm_code'] = $user->confirm_code; $bodyHtml = array_get($data, 'body_html');