security.txt + bugfixes

This commit is contained in:
2023-03-05 15:30:06 +01:00
parent 5acd13b945
commit c8965e209b
32 changed files with 336 additions and 46 deletions

View File

@@ -227,9 +227,9 @@ namespace Core\API\Mail {
$sql = $this->context->getSQL();
$mailQueueItems = MailQueueItem::findBy(MailQueueItem::createBuilder($sql, false)
->whereGt("retryCount", 0)
->whereGt("retry_count", 0)
->whereEq("status", "waiting")
->where(new Compare("nextTry", $sql->now(), "<=")));
->where(new Compare("next_try", $sql->now(), "<=")));
$this->success = ($mailQueueItems !== false);
$this->lastError = $sql->getLastError();

View File

@@ -68,6 +68,7 @@ class Swagger extends Request {
foreach (self::getApiEndpoints() as $endpoint => $apiClass) {
$body = null;
$requiredProperties = [];
$endpoint = "/$endpoint";
$apiObject = $apiClass->newInstance($this->context, false);
if (!$this->canView($permissions[strtolower($endpoint)] ?? [], $apiObject)) {
continue;

View File

@@ -222,7 +222,7 @@ namespace Core\API\User {
public function __construct(Context $context, $externalCall = false) {
parent::__construct($context, $externalCall,
self::getPaginationParameters(['id', 'name', 'email', 'groups', 'registeredAt'],
self::getPaginationParameters(['id', 'name', 'fullName', 'email', 'groups', 'registeredAt', 'confirmed'],
'id', 'asc')
);
}
@@ -341,7 +341,9 @@ namespace Core\API\User {
$this->result["loggedIn"] = true;
$userGroups = array_keys($currentUser->getGroups());
$this->result["user"] = $currentUser->jsonSerialize();
$this->result["session"] = $this->context->getSession()->jsonSerialize();
$this->result["session"] = $this->context->getSession()->jsonSerialize([
"id", "expires", "stayLoggedIn", "data", "csrfToken"
]);
}
$sql = $this->context->getSQL();
@@ -1022,7 +1024,7 @@ namespace Core\API\User {
$userToken = UserToken::findBy(UserToken::createBuilder($sql, true)
->whereFalse("used")
->whereEq("tokenType", UserToken::TYPE_EMAIL_CONFIRM)
->whereEq("token_type", UserToken::TYPE_EMAIL_CONFIRM)
->whereEq("user_id", $user->getId()));
$validHours = 48;

View File

@@ -85,7 +85,6 @@ class CreateDatabase extends DatabaseScript {
$method = "$className::getHandler";
$handler = call_user_func($method, $sql, null, true);
$persistables[$handler->getTableName()] = $handler;
foreach ($handler->getNMRelations() as $nmTableName => $nmRelation) {
$persistables[$nmTableName] = $nmRelation;
}

View File

@@ -107,7 +107,7 @@ class Settings {
public static function loadDefaults(): Settings {
$hostname = $_SERVER["SERVER_NAME"] ?? null;
if (empty($hostname)) {
$hostname = $_SERVER["HTTP_HOST"];
$hostname = $_SERVER["HTTP_HOST"] ?? null;
if (empty($hostname)) {
$hostname = "localhost";
}

View File

@@ -0,0 +1,99 @@
<?php
namespace Core\Documents;
use Core\Configuration\Settings;
use Core\Elements\Document;
use Core\Objects\DatabaseEntity\GpgKey;
use Core\Objects\DatabaseEntity\Language;
use Core\Objects\Router\Router;
// Source: https://www.rfc-editor.org/rfc/rfc9116
class Security extends Document {
public function __construct(Router $router) {
parent::__construct($router);
$this->searchable = false;
}
public function getTitle(): string {
return "security.txt";
}
public function getCode(array $params = []) {
$activeRoute = $this->router->getActiveRoute();
$sql = $this->getContext()->getSQL();
$settings = $this->getSettings();
$mailSettings = Settings::getAll($sql, "^mail_");
if ($activeRoute->getPattern() === "/.well-known/security.txt") {
// The order in which they appear is not an indication of priority; the listed languages are intended to have equal priority.
$languageCodes = implode(", ", array_map(function (Language $language) {
return $language->getShortCode();
}, Language::findAll($sql)));
$expires = (new \DateTime())->setTime(0, 0, 0)->modify("+3 months");
$baseUrl = $settings->getBaseUrl();
$gpgKey = null;
$lines = [
"# This project is based on the open-source framework hosted on https://github.com/rhergenreder/web-base",
"# Any non-site specific issues can be reported via the github security reporting feature:",
"# https://docs.github.com/en/code-security/security-advisories/guidance-on-reporting-and-writing/privately-reporting-a-security-vulnerability",
"",
"Canonical: $baseUrl/.well-known/security.txt",
"Preferred-Languages: $languageCodes",
"Expires: " . $expires->format(\DateTime::ATOM),
"",
];
if (isset($mailSettings["mail_contact"])) {
$lines[] = "Contact: " . $mailSettings["mail_contact"];
if (isset($mailSettings["mail_contact_gpg_key_id"])) {
$gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]);
if ($gpgKey) {
$lines[] = "Encryption: $baseUrl/.well-known/gpg-key.txt";
}
}
}
$code = implode("\n", $lines);
if ($gpgKey !== null) {
$res = GpgKey::sign($code, $gpgKey->getFingerprint());
if ($res["success"]) {
$code = $res["data"];
}
}
return $code;
} else if ($activeRoute->getPattern() === "/.well-known/gpg-key.txt") {
if (isset($mailSettings["mail_contact_gpg_key_id"])) {
$gpgKey = GpgKey::find($sql, $mailSettings["mail_contact_gpg_key_id"]);
if ($gpgKey !== null) {
header("Content-Type: text/plain");
$res = $gpgKey->_export(true);
if ($res["success"]) {
return $res["data"];
} else {
return "Error exporting public key: " . $res["msg"];
}
}
}
}
http_response_code(404);
return "";
}
public function sendHeaders(): void {
parent::sendHeaders();
header("Content-Type: text/plain");
}
}

View File

@@ -89,7 +89,7 @@ abstract class Document {
}
}
public function sendHeaders() {
public function sendHeaders(): void {
if ($this->cspEnabled) {
$cspWhiteList = implode(" ", $this->cspWhitelist);
$csp = [
@@ -98,7 +98,8 @@ abstract class Document {
"base-uri 'self'",
"style-src 'self' 'unsafe-inline'",
"img-src 'self' 'unsafe-inline' data: https:;",
"script-src $cspWhiteList 'nonce-$this->cspNonce'"
"script-src $cspWhiteList 'nonce-$this->cspNonce'",
"frame-ancestors 'self'",
];
if ($this->getSettings()->isRecaptchaEnabled()) {
$csp[] = "frame-src https://www.google.com/ 'self'";
@@ -107,6 +108,15 @@ abstract class Document {
$compiledCSP = implode("; ", $csp);
header("Content-Security-Policy: $compiledCSP;");
}
// additional security headers
header("X-XSS-Protection: 0"); // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
header("X-Content-Type-Options: nosniff");
if (getProtocol() === "https") {
$maxAge = 365 * 24 * 60 * 60; // 1 year in seconds
header("Strict-Transport-Security: max-age=$maxAge; includeSubDomains; preload");
}
}
public abstract function getCode(array $params = []);

View File

@@ -2,6 +2,7 @@
return [
"title" => "Account",
"users" => "Benutzer",
"login_title" => "Einloggen",
"login_description" => "Loggen Sie sich in Ihren Account ein",
"accept_invite_title" => "Einladung",
@@ -32,6 +33,7 @@ return [
"remember_me" => "Eingeloggt bleiben",
"signing_in" => "Einloggen",
"sign_in" => "Einloggen",
"confirmed" => "Bestätigt",
"forgot_password" => "Passwort vergessen?",
"change_password" => "Passwort ändern",
"passwords_do_not_match" => "Die Passwörter stimmen nicht überein",
@@ -57,4 +59,6 @@ return [
"error_profile_get" => "Fehler beim Laden des Benutzerprofils",
"registered_at" => "Registriert am",
"last_online" => "Zuletzt online",
"groups" => "Gruppen",
"logged_in_as" => "Eingeloggt als",
];

View File

@@ -7,6 +7,7 @@ return [
"go_back" => "Zurück",
"save" => "Speichern",
"save_only" => "Nur Speichern",
"saved" => "Gespeichert",
"saving" => "Speichere",
"unsaved_changes" => "Du hast nicht gespeicherte Änderungen",
"new" => "Neu",
@@ -19,6 +20,8 @@ return [
"confirm" => "Bestätigen",
"add" => "Hinzufügen",
"ok" => "OK",
"id" => "ID",
"user" => "Benutzer",
"language" => "Sprache",
"loading" => "Laden",
"logout" => "Ausloggen",

View File

@@ -10,6 +10,7 @@ return [
"token" => "Token",
"request_new_key" => "Neuen Schlüssel anfordern",
"show_only_active_keys" => "Zeige nur aktive Schlüssel",
"no_api_key_registered" => "Keine gültigen API-Schlüssel registriert",
# GPG Key
"gpg_key_placeholder_text" => "GPG-Key im ASCII format reinziehen oder einfügen...",

View File

@@ -2,6 +2,7 @@
return [
"title" => "Account",
"users" => "Users",
"login_title" => "Sign In",
"login_description" => "Sign In into your account",
"accept_invite_title" => "Invitation",
@@ -32,6 +33,7 @@ return [
"remember_me" => "Remember Me",
"signing_in" => "Signing in",
"sign_in" => "Sign In",
"confirmed" => "Confirmed",
"forgot_password" => "Forgot password?",
"change_password" => "Change password",
"register_text" => "Don't have an account? Sign Up",
@@ -57,4 +59,6 @@ return [
"error_profile_get" => "Error retrieving user profile",
"registered_at" => "Registered At",
"last_online" => "Last Online",
"groups" => "Groups",
"logged_in_as" => "Logged in as",
];

View File

@@ -31,6 +31,8 @@ return [
"add" => "Add",
"close" => "Close",
"ok" => "OK",
"id" => "ID",
"user" => "User",
"remove" => "Remove",
"change" => "Change",
"reset" => "Reset",
@@ -38,6 +40,7 @@ return [
"go_back" => "Go Back",
"save" => "Save",
"save_only" => "Save Only",
"saved" => "Saved",
"saving" => "Saving",
"delete" => "Delete",
"info" => "Info",

View File

@@ -10,6 +10,7 @@ return [
"token" => "Token",
"request_new_key" => "Request new Key",
"show_only_active_keys" => "Show only active keys",
"no_api_key_registered" => "No valid API-Keys registered",
# GPG Key
"gpg_key_placeholder_text" => "Paste or drag'n'drop your GPG-Key in ASCII format...",

View File

@@ -0,0 +1,16 @@
<?php
namespace Core\Objects\DatabaseEntity\Attribute;
#[\Attribute(\Attribute::TARGET_CLASS)] class UsePropertiesOf {
private string $class;
public function __construct(string $class) {
$this->class = $class;
}
public function getClass(): string {
return $this->class;
}
}

View File

@@ -38,6 +38,7 @@ use Core\Objects\DatabaseEntity\Attribute\MultipleReference;
use Core\Objects\DatabaseEntity\Attribute\NoFetch;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Attribute\Unique;
use Core\Objects\DatabaseEntity\Attribute\UsePropertiesOf;
class DatabaseEntityHandler implements Persistable {
@@ -75,14 +76,17 @@ class DatabaseEntityHandler implements Persistable {
public function init() {
$className = $this->entityClass->getName();
$entityClass = $this->entityClass;
while ($usePropsOf = self::getAttribute($entityClass, UsePropertiesOf::class)) {
$entityClass = new \ReflectionClass($usePropsOf->getClass());
}
$uniqueColumns = self::getAttribute($this->entityClass, Unique::class);
$uniqueColumns = self::getAttribute($entityClass, Unique::class);
if ($uniqueColumns) {
$this->constraints[] = new \Core\Driver\SQL\Constraint\Unique($uniqueColumns->getColumns());
}
foreach ($this->entityClass->getProperties() as $property) {
foreach ($entityClass->getProperties() as $property) {
$propertyName = $property->getName();
if ($propertyName === "id") {
$this->properties[$propertyName] = $property;
@@ -123,8 +127,8 @@ class DatabaseEntityHandler implements Persistable {
try {
$requestedClass = new \ReflectionClass($extendingClass);
if (!$requestedClass->isSubclassOf($this->entityClass)) {
$this->raiseError("Class '$extendingClass' must be an inheriting from '" . $this->entityClass->getName() . "' for an extending enum");
if (!$requestedClass->isSubclassOf($entityClass)) {
$this->raiseError("Class '$extendingClass' must be an inheriting from '" . $entityClass->getName() . "' for an extending enum");
} else {
$this->extendingClasses[$key] = $requestedClass;
}

View File

@@ -27,6 +27,10 @@ class GpgKey extends DatabaseEntity {
$this->added = new \DateTime();
}
public function _encrypt(string $body): array {
return self::encrypt($body, $this->fingerprint);
}
public static function encrypt(string $body, string $gpgFingerprint): array {
$gpgFingerprint = escapeshellarg($gpgFingerprint);
$cmd = self::GPG2 . " --encrypt --output - --recipient $gpgFingerprint --trust-model always --batch --armor";
@@ -40,6 +44,23 @@ class GpgKey extends DatabaseEntity {
}
}
public function _sign(string $body): array {
return self::sign($body, $this->fingerprint);
}
public static function sign(string $body, string $gpgFingerprint): array {
$gpgFingerprint = escapeshellarg($gpgFingerprint);
$cmd = self::GPG2 . " --clearsign --output - --local-user $gpgFingerprint --batch --armor";
list($out, $err) = self::proc_exec($cmd, $body, true);
if ($out === null) {
return createError("Error while communicating with GPG agent");
} else if ($err) {
return createError($err);
} else {
return ["success" => true, "data" => $out];
}
}
private static function proc_exec(string $cmd, ?string $stdin = null, bool $raw = false): ?array {
$descriptorSpec = array(0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]);
$process = proc_open($cmd, $descriptorSpec, $pipes);
@@ -101,7 +122,11 @@ class GpgKey extends DatabaseEntity {
return createError($err);
}
public static function export($gpgFingerprint, bool $armored): array {
public function _export(bool $armored = true): array {
return self::export($this->fingerprint, $armored);
}
public static function export(string $gpgFingerprint, bool $armored): array {
$cmd = self::GPG2 . " --export ";
if ($armored) {
$cmd .= "--armor ";

View File

@@ -2,7 +2,6 @@
namespace Core\Objects\DatabaseEntity {
use Core\Driver\SQL\SQL;
use Core\Objects\DatabaseEntity\Attribute\MaxLength;
use Core\Objects\DatabaseEntity\Attribute\Transient;
use Core\Objects\DatabaseEntity\Controller\DatabaseEntity;

View File

@@ -249,6 +249,8 @@ abstract class Route extends DatabaseEntity {
new DocumentRoute("/login", true, \Core\Documents\Account::class, "account/login.twig"),
new DocumentRoute("/resendConfirmEmail", true, \Core\Documents\Account::class, "account/resend_confirm_email.twig"),
new DocumentRoute("/debug", true, \Core\Documents\Info::class),
new DocumentRoute("/.well-known/security.txt", true, \Core\Documents\Security::class),
new DocumentRoute("/.well-known/gpg-key.txt", true, \Core\Documents\Security::class),
new StaticFileRoute("/", true, "/static/welcome.html"),
];
}

View File

@@ -44,6 +44,16 @@ class Session extends DatabaseEntity {
return null;
}
if (is_array($session->data)) {
foreach ($session->data as $key => $value) {
$_SESSION[$key] = $value;
if ($key === "2faAuthenticated" && $value === true) {
$tfaToken = $session->getUser()->getTwoFactorToken();
$tfaToken?->authenticate();
}
}
}
$session->context = $context;
return $session;
}
@@ -66,6 +76,7 @@ class Session extends DatabaseEntity {
}
public function setData(array $data) {
$this->data = $data;
foreach ($data as $key => $value) {
$_SESSION[$key] = $value;
}
@@ -107,7 +118,7 @@ class Session extends DatabaseEntity {
$sql = $this->context->getSQL();
return $this->user->update($sql) &&
$this->save($sql, ["expires", "data"]);
$this->save($sql, ["expires", "data", "os", "browser"]);
}
public function getCsrfToken(): string {

View File

@@ -2,21 +2,23 @@
{% set view_title = 'account.resend_confirm_email_title' %}
{% set view_icon = 'envelope' %}
{% set view_description = 'resend_confirm_email_description' %}
{% set view_description = 'account.resend_confirm_email_description' %}
{% block view_content %}
<p class='lead'>
{{ L("account.resend_confirm_email_form_title") }}
</p>
<form>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
<form style="display: flex; flex-direction: column; justify-content: space-between; height: 100%">
<div>
<p class='lead'>
{{ L("account.resend_confirm_email_form_title") }}
</p>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
</div>
<input id="email" autocomplete='email' name="email" placeholder="{{ L('account.email') }}"
class="form-control" type="email" maxlength="64" />
</div>
<input id="email" autocomplete='email' name="email" placeholder="{{ L('account.email') }}"
class="form-control" type="email" maxlength="64" />
</div>
<div class="input-group mt-2" style='position: absolute;bottom: 15px'>
<div class="input-group mt-2">
<button id='btnResendConfirmEmail' class='btn btn-primary'>
{{ L('general.request') }}
</button>

View File

@@ -40,18 +40,20 @@
</form>
{% endif %}
{% else %}
<p class='lead'>
{{ L("account.reset_password_request_form_title") }}
</p>
<form>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
<form style="display: flex; flex-direction: column; justify-content: space-between; height: 100%">
<div>
<p class='lead'>
{{ L("account.reset_password_request_form_title") }}
</p>
<div class="input-group">
<div class="input-group-append">
<span class="input-group-text"><i class="fas fa-at"></i></span>
</div>
<input id="email" autocomplete='email' name="email" placeholder="{{ L('account.email') }}"
class="form-control" type="email" maxlength="64" />
</div>
<input id="email" autocomplete='email' name="email" placeholder="{{ L('account.email') }}"
class="form-control" type="email" maxlength="64" />
</div>
<div class="input-group mt-2" style='position: absolute;bottom: 15px'>
<div class="input-group mt-2">
<button id='btnRequestPasswordReset' class='btn btn-primary'>
{{ L('general.submit') }}
</button>