security.txt + bugfixes

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

@ -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();

@ -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;

@ -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;

@ -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;
}

@ -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";
}

@ -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");
}
}

@ -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 = []);

@ -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",
];

@ -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",

@ -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...",

@ -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",
];

@ -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",

@ -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...",

@ -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;
}
}

@ -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;
}

@ -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 ";

@ -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;

@ -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"),
];
}

@ -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 {

@ -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>

@ -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>

@ -15,6 +15,10 @@ RUN apt-get update -y && \
printf "\n" | pecl install yaml imagick && docker-php-ext-enable yaml imagick && \
docker-php-ext-install gd
# Browscap
RUN mkdir -p /usr/local/etc/php/extra/ && \
curl -s "https://browscap.org/stream?q=Full_PHP_BrowsCapINI" -o /usr/local/etc/php/extra/browscap.ini
# NodeJS
RUN curl -sL https://deb.nodesource.com/setup_14.x | bash - && \
apt-get update && \

@ -932,7 +932,7 @@ default_socket_timeout = 60
;extension=ldap
;extension=mbstring
;extension=exif ; Must be after mbstring as it depends on it
extension=mysqli
;extension=mysqli
;extension=oci8_12c ; Use with Oracle Database 12c Instant Client
;extension=oci8_19 ; Use with Oracle Database 19 Instant Client
;extension=odbc
@ -1318,7 +1318,7 @@ bcmath.scale = 0
[browscap]
; https://php.net/browscap
;browscap = extra/browscap.ini
browscap = /usr/local/etc/php/extra/browscap.ini
[Session]
; Handler used to store/retrieve data.

@ -52,7 +52,7 @@ $(document).ready(function () {
$("#password").val("");
createdDiv.hide();
if (res.emailConfirmed === false) {
showAlert("danger", res.msg + ' <a href="/resendConfirmation">Click here</a> to resend the confirmation mail.', true);
showAlert("danger", res.msg + ' <a href="/resendConfirmEmail">Click here</a> to resend the confirmation mail.', true);
} else {
showAlert("danger", res.msg);
}

@ -19,7 +19,7 @@ function setState(state) {
case SUCCESSFUL:
icon = 'fas fa-check-circle';
text = "Successfull";
text = "Successful";
color = "success";
break;
@ -112,7 +112,7 @@ function waitForStatusChange() {
$(document).ready(function() {
$("#btnSubmit").click(function() {
params = { };
let params = { };
let submitButton = $("#btnSubmit");
let textBefore = submitButton.text();
submitButton.prop("disabled", true);
@ -143,7 +143,7 @@ $(document).ready(function() {
$("#btnPrev").prop("disabled", true);
sendRequest({ "prev": true }, function(success) {
if(!success) {
$("#btnPrev").prop("disabled",false);
$("#btnPrev").prop("disabled", false);
} else {
window.location.reload();
}

@ -23,6 +23,10 @@
text-align: center;
}
.font-monospace {
font-family: monospace;
}
.data-table-clickable {
cursor: pointer;
}

@ -184,6 +184,60 @@ export class StringColumn extends DataColumn {
}
}
export class ArrayColumn extends DataColumn {
constructor(label, field = null, params = {}) {
super(label, field, params);
this.seperator = params.seperator || ", ";
}
renderData(L, entry, index) {
let data = super.renderData(L, entry, index);
if (!Array.isArray(data)) {
data = Object.values(data);
}
data = data.join(this.seperator);
if (this.params.style) {
let style = (typeof this.params.style === 'function'
? this.params.style(entry) : this.params.style);
data = <span style={style}>{data}</span>
}
return data;
}
}
export class SecretsColumn extends DataColumn {
constructor(label, field = null, params = {}) {
super(label, field, params);
this.asteriskCount = params.asteriskCount || 8;
this.character = params.character || "*";
this.canCopy = params.hasOwnProperty("canCopy") ? params.canCopy : true;
}
renderData(L, entry, index) {
let originalData = super.renderData(L, entry, index);
if (!originalData) {
return "(None)";
}
let properties = this.params.properties || {};
properties.className = clsx(properties.className, "font-monospace");
if (this.canCopy) {
properties.title = L("Click to copy");
properties.className = clsx(properties.className, "data-table-clickable");
properties.onClick = () => {
navigator.clipboard.writeText(originalData);
};
}
return <span {...properties}>{this.character.repeat(this.asteriskCount)}</span>
}
}
export class NumericColumn extends DataColumn {
constructor(label, field = null, params = {}) {
super(label, field, params);

@ -7,7 +7,7 @@ import {
DialogContent,
DialogContentText,
DialogTitle,
Input, List, ListItem, Select, TextField
Input, List, ListItem, TextField
} from "@mui/material";
export default function Dialog(props) {
@ -48,8 +48,10 @@ export default function Dialog(props) {
switch (input.type) {
case 'text':
case 'password':
inputElements.push(<TextField
{...inputProps}
type={input.type}
sx={{marginTop: 1}}
size={"small"} fullWidth={true}
key={"input-" + input.name}

@ -0,0 +1,16 @@
import {useCallback, useEffect} from "react";
export default function useBeforeUnload(modified) {
const capture = useCallback((event) => {
if (modified) {
event.preventDefault();
}
}, [modified]);
useEffect(() => {
window.addEventListener("beforeunload", capture, {capture: true});
return () => window.removeEventListener("beforeunload", capture, { capture: true });
}, []);
}

@ -0,0 +1,21 @@
import {useNavigate} from "react-router-dom";
export default function useEditorNavigate(L, showDialog) {
const navigate = useNavigate();
return (uri, modified, options = null) => {
if (!modified) {
navigate(uri, options ?? {});
} else {
showDialog(
"You still have unsaved changes, are you really sure you want to leave this view?",
"Unsaved changes",
[L("general.cancel"), L("general.leave")],
(buttonIndex) => buttonIndex === 1 && navigate(uri, options ?? {})
)
}
};
}

@ -297,7 +297,7 @@ export default function LoginForm(props) {
? <Alert severity="error">
{error}
{emailConfirmed === false
? <> <Link href={"/resendConfirmation"}>Click here</Link> to resend the confirmation email.</>
? <> <Link href={"/resendConfirmEmail"}>Click here</Link> to resend the confirmation email.</>
: <></>
}
</Alert>

@ -1,3 +1,4 @@
User-agent: *
Disallow: /Core
Disallow: /Site
Disallow: /admin