Some Backend Admin fixes and updates (#5272)

* Some Backend Admin fixes and updates

- Updated datatables
- Added a `X-Robots-Tags` header to prevent indexing
- Modified some layout settings
- Added Websocket check to diagnostics
- Added Security Header checks to diagnostics
- Added Error page response checks to diagnostics
- Modifed support string layout a bit

Signed-off-by: BlackDex <black.dex@gmail.com>

* Some small fixes

Signed-off-by: BlackDex <black.dex@gmail.com>

---------

Signed-off-by: BlackDex <black.dex@gmail.com>
This commit is contained in:
Mathijs van Veluw 2024-12-10 21:52:12 +01:00 committed by GitHub
parent 620ad92331
commit 45e5f06b86
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 1194 additions and 512 deletions

View File

@ -62,6 +62,7 @@ pub fn routes() -> Vec<Route> {
diagnostics,
get_diagnostics_config,
resend_user_invite,
get_diagnostics_http,
]
}
@ -713,6 +714,7 @@ async fn diagnostics(_token: AdminToken, ip_header: IpHeader, mut conn: DbConn)
"ip_header_name": ip_header_name,
"ip_header_config": &CONFIG.ip_header(),
"uses_proxy": uses_proxy,
"enable_websocket": &CONFIG.enable_websocket(),
"db_type": *DB_TYPE,
"db_version": get_sql_server_version(&mut conn).await,
"admin_url": format!("{}/diagnostics", admin_url()),
@ -734,6 +736,11 @@ fn get_diagnostics_config(_token: AdminToken) -> Json<Value> {
Json(support_json)
}
#[get("/diagnostics/http?<code>")]
fn get_diagnostics_http(code: u16, _token: AdminToken) -> EmptyResult {
err_code!(format!("Testing error {code} response"), code);
}
#[post("/config", data = "<data>")]
fn post_config(data: Json<ConfigBuilder>, _token: AdminToken) -> EmptyResult {
let data: ConfigBuilder = data.into_inner();

View File

@ -38,8 +38,8 @@ img {
max-width: 130px;
}
#users-table .vw-actions, #orgs-table .vw-actions {
min-width: 130px;
max-width: 130px;
min-width: 135px;
max-width: 140px;
}
#users-table .vw-org-cell {
max-height: 120px;

View File

@ -7,6 +7,8 @@ var timeCheck = false;
var ntpTimeCheck = false;
var domainCheck = false;
var httpsCheck = false;
var websocketCheck = false;
var httpResponseCheck = false;
// ================================
// Date & Time Check
@ -76,18 +78,15 @@ async function generateSupportString(event, dj) {
event.preventDefault();
event.stopPropagation();
let supportString = "### Your environment (Generated via diagnostics page)\n";
let supportString = "### Your environment (Generated via diagnostics page)\n\n";
supportString += `* Vaultwarden version: v${dj.current_release}\n`;
supportString += `* Web-vault version: v${dj.web_vault_version}\n`;
supportString += `* OS/Arch: ${dj.host_os}/${dj.host_arch}\n`;
supportString += `* Running within a container: ${dj.running_within_container} (Base: ${dj.container_base_image})\n`;
supportString += "* Environment settings overridden: ";
if (dj.overrides != "") {
supportString += "true\n";
} else {
supportString += "false\n";
}
supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`;
supportString += `* Environment settings overridden!: ${dj.overrides !== ""}\n`;
supportString += `* Uses a reverse proxy: ${dj.ip_header_exists}\n`;
if (dj.ip_header_exists) {
supportString += `* IP Header check: ${dj.ip_header_match} (${dj.ip_header_name})\n`;
@ -99,11 +98,12 @@ async function generateSupportString(event, dj) {
supportString += `* Server/NTP Time Check: ${ntpTimeCheck}\n`;
supportString += `* Domain Configuration Check: ${domainCheck}\n`;
supportString += `* HTTPS Check: ${httpsCheck}\n`;
supportString += `* Database type: ${dj.db_type}\n`;
supportString += `* Database version: ${dj.db_version}\n`;
supportString += "* Clients used: \n";
supportString += "* Reverse proxy and version: \n";
supportString += "* Other relevant information: \n";
if (dj.enable_websocket) {
supportString += `* Websocket Check: ${websocketCheck}\n`;
} else {
supportString += "* Websocket Check: disabled\n";
}
supportString += `* HTTP Response Checks: ${httpResponseCheck}\n`;
const jsonResponse = await fetch(`${BASE_URL}/admin/diagnostics/config`, {
"headers": { "Accept": "application/json" }
@ -113,10 +113,30 @@ async function generateSupportString(event, dj) {
throw new Error(jsonResponse);
}
const configJson = await jsonResponse.json();
supportString += "\n### Config (Generated via diagnostics page)\n<details><summary>Show Running Config</summary>\n";
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
supportString += "\n\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n</details>\n";
// Start Config and Details section within a details block which is collapsed by default
supportString += "\n### Config & Details (Generated via diagnostics page)\n\n";
supportString += "<details><summary>Show Config & Details</summary>\n";
// Add overrides if they exists
if (dj.overrides != "") {
supportString += `\n**Environment settings which are overridden:** ${dj.overrides}\n`;
}
// Add http response check messages if they exists
if (httpResponseCheck === false) {
supportString += "\n**Failed HTTP Checks:**\n";
// We use `innerText` here since that will convert <br> into new-lines
supportString += "\n```yaml\n" + document.getElementById("http-response-errors").innerText.trim() + "\n```\n";
}
// Add the current config in json form
supportString += "\n**Config:**\n";
supportString += "\n```json\n" + JSON.stringify(configJson, undefined, 2) + "\n```\n";
supportString += "\n</details>\n";
// Add the support string to the textbox so it can be viewed and copied
document.getElementById("support-string").textContent = supportString;
document.getElementById("support-string").classList.remove("d-none");
document.getElementById("copy-support").classList.remove("d-none");
@ -199,6 +219,162 @@ function checkDns(dns_resolved) {
}
}
async function fetchCheckUrl(url) {
try {
const response = await fetch(url);
return { headers: response.headers, status: response.status, text: await response.text() };
} catch (error) {
console.error(`Error fetching ${url}: ${error}`);
return { error };
}
}
function checkSecurityHeaders(headers, omit) {
let securityHeaders = {
"x-frame-options": ["SAMEORIGIN"],
"x-content-type-options": ["nosniff"],
"referrer-policy": ["same-origin"],
"x-xss-protection": ["0"],
"x-robots-tag": ["noindex", "nofollow"],
"content-security-policy": [
"default-src 'self'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'self' blob:",
"script-src 'self' 'wasm-unsafe-eval'",
"style-src 'self' 'unsafe-inline'",
"child-src 'self' https://*.duosecurity.com https://*.duofederal.com",
"frame-src 'self' https://*.duosecurity.com https://*.duofederal.com",
"frame-ancestors 'self' chrome-extension://nngceckbapebfimnlniiiahkandclblb chrome-extension://jbkfoedolllekgbhcbcoahefnbanhhlh moz-extension://*",
"img-src 'self' data: https://haveibeenpwned.com",
"connect-src 'self' https://api.pwnedpasswords.com https://api.2fa.directory https://app.simplelogin.io/api/ https://app.addy.io/api/ https://api.fastmail.com/ https://api.forwardemail.net",
]
};
let messages = [];
for (let header in securityHeaders) {
// Skip some headers for specific endpoints if needed
if (typeof omit === "object" && omit.includes(header) === true) {
continue;
}
// If the header exists, check if the contents matches what we expect it to be
let headerValue = headers.get(header);
if (headerValue !== null) {
securityHeaders[header].forEach((expectedValue) => {
if (headerValue.indexOf(expectedValue) === -1) {
messages.push(`'${header}' does not contain '${expectedValue}'`);
}
});
} else {
messages.push(`'${header}' is missing!`);
}
}
return messages;
}
async function checkHttpResponse() {
const [apiConfig, webauthnConnector, notFound, notFoundApi, badRequest, unauthorized, forbidden] = await Promise.all([
fetchCheckUrl(`${BASE_URL}/api/config`),
fetchCheckUrl(`${BASE_URL}/webauthn-connector.html`),
fetchCheckUrl(`${BASE_URL}/admin/does-not-exist`),
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=404`),
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=400`),
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=401`),
fetchCheckUrl(`${BASE_URL}/admin/diagnostics/http?code=403`),
]);
const respErrorElm = document.getElementById("http-response-errors");
// Check and validate the default API header responses
let apiErrors = checkSecurityHeaders(apiConfig.headers);
if (apiErrors.length >= 1) {
respErrorElm.innerHTML += "<b>API calls:</b><br>";
apiErrors.forEach((errMsg) => {
respErrorElm.innerHTML += `<b>Header:</b> ${errMsg}<br>`;
});
}
// Check the special `-connector.html` headers, these should have some headers omitted.
const omitConnectorHeaders = ["x-frame-options", "content-security-policy"];
let connectorErrors = checkSecurityHeaders(webauthnConnector.headers, omitConnectorHeaders);
omitConnectorHeaders.forEach((header) => {
if (webauthnConnector.headers.get(header) !== null) {
connectorErrors.push(`'${header}' is present while it should not`);
}
});
if (connectorErrors.length >= 1) {
respErrorElm.innerHTML += "<b>2FA Connector calls:</b><br>";
connectorErrors.forEach((errMsg) => {
respErrorElm.innerHTML += `<b>Header:</b> ${errMsg}<br>`;
});
}
// Check specific error code responses if they are not re-written by a reverse proxy
let responseErrors = [];
if (notFound.status !== 404 || notFound.text.indexOf("return to the web-vault") === -1) {
responseErrors.push("404 (Not Found) HTML is invalid");
}
if (notFoundApi.status !== 404 || notFoundApi.text.indexOf("\"message\":\"Testing error 404 response\",") === -1) {
responseErrors.push("404 (Not Found) JSON is invalid");
}
if (badRequest.status !== 400 || badRequest.text.indexOf("\"message\":\"Testing error 400 response\",") === -1) {
responseErrors.push("400 (Bad Request) is invalid");
}
if (unauthorized.status !== 401 || unauthorized.text.indexOf("\"message\":\"Testing error 401 response\",") === -1) {
responseErrors.push("401 (Unauthorized) is invalid");
}
if (forbidden.status !== 403 || forbidden.text.indexOf("\"message\":\"Testing error 403 response\",") === -1) {
responseErrors.push("403 (Forbidden) is invalid");
}
if (responseErrors.length >= 1) {
respErrorElm.innerHTML += "<b>HTTP error responses:</b><br>";
responseErrors.forEach((errMsg) => {
respErrorElm.innerHTML += `<b>Response to:</b> ${errMsg}<br>`;
});
}
if (responseErrors.length >= 1 || connectorErrors.length >= 1 || apiErrors.length >= 1) {
document.getElementById("http-response-warning").classList.remove("d-none");
} else {
httpResponseCheck = true;
document.getElementById("http-response-success").classList.remove("d-none");
}
}
async function fetchWsUrl(wsUrl) {
return new Promise((resolve, reject) => {
try {
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
ws.close();
resolve(true);
};
ws.onerror = () => {
reject(false);
};
} catch (_) {
reject(false);
}
});
}
async function checkWebsocketConnection() {
// Test Websocket connections via the anonymous (login with device) connection
const isConnected = await fetchWsUrl(`${BASE_URL}/notifications/anonymous-hub?token=admin-diagnostics`).catch(() => false);
if (isConnected) {
websocketCheck = true;
document.getElementById("websocket-success").classList.remove("d-none");
} else {
document.getElementById("websocket-error").classList.remove("d-none");
}
}
function init(dj) {
// Time check
document.getElementById("time-browser-string").textContent = browserUTC;
@ -225,6 +401,12 @@ function init(dj) {
// DNS Check
checkDns(dj.dns_resolved);
checkHttpResponse();
if (dj.enable_websocket) {
checkWebsocketConnection();
}
}
// onLoad events

View File

@ -4,10 +4,10 @@
*
* To rebuild or modify this file with the latest versions of the included
* software please visit:
* https://datatables.net/download/#bs5/dt-2.0.8
* https://datatables.net/download/#bs5/dt-2.1.8
*
* Included libraries:
* DataTables 2.0.8
* DataTables 2.1.8
*/
@charset "UTF-8";
@ -45,15 +45,21 @@ table.dataTable tr.dt-hasChild td.dt-control:before {
}
html.dark table.dataTable td.dt-control:before,
:root[data-bs-theme=dark] table.dataTable td.dt-control:before {
:root[data-bs-theme=dark] table.dataTable td.dt-control:before,
:root[data-theme=dark] table.dataTable td.dt-control:before {
border-left-color: rgba(255, 255, 255, 0.5);
}
html.dark table.dataTable tr.dt-hasChild td.dt-control:before,
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
:root[data-bs-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before,
:root[data-theme=dark] table.dataTable tr.dt-hasChild td.dt-control:before {
border-top-color: rgba(255, 255, 255, 0.5);
border-left-color: transparent;
}
div.dt-scroll {
width: 100%;
}
div.dt-scroll-body thead tr,
div.dt-scroll-body tfoot tr {
height: 0;
@ -377,6 +383,31 @@ table.table.dataTable.table-hover > tbody > tr.selected:hover > * {
box-shadow: inset 0 0 0 9999px rgba(var(--dt-row-selected), 0.975);
}
div.dt-container div.dt-layout-start > *:not(:last-child) {
margin-right: 1em;
}
div.dt-container div.dt-layout-end > *:not(:first-child) {
margin-left: 1em;
}
div.dt-container div.dt-layout-full {
width: 100%;
}
div.dt-container div.dt-layout-full > *:only-child {
margin-left: auto;
margin-right: auto;
}
div.dt-container div.dt-layout-table > div {
display: block !important;
}
@media screen and (max-width: 767px) {
div.dt-container div.dt-layout-start > *:not(:last-child) {
margin-right: 0;
}
div.dt-container div.dt-layout-end > *:not(:first-child) {
margin-left: 0;
}
}
div.dt-container div.dt-length label {
font-weight: normal;
text-align: left;
@ -400,9 +431,6 @@ div.dt-container div.dt-search input {
display: inline-block;
width: auto;
}
div.dt-container div.dt-info {
padding-top: 0.85em;
}
div.dt-container div.dt-paging {
margin: 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -132,6 +132,21 @@
<span class="d-block" title="We have direct internet access, no outgoing proxy configured."><b>No</b></span>
{{/unless}}
</dd>
<dt class="col-sm-5">Websocket enabled
{{#if page_data.enable_websocket}}
<span class="badge bg-success d-none" id="websocket-success" title="Websocket connection is working.">Ok</span>
<span class="badge bg-danger d-none" id="websocket-error" title="Websocket connection error, validate your reverse proxy configuration!">Error</span>
{{/if}}
</dt>
<dd class="col-sm-7">
{{#if page_data.enable_websocket}}
<span class="d-block" title="Websocket connections are enabled (ENABLE_WEBSOCKET is true)."><b>Yes</b></span>
{{/if}}
{{#unless page_data.enable_websocket}}
<span class="d-block" title="Websocket connections are disabled (ENABLE_WEBSOCKET is false)."><b>No</b></span>
{{/unless}}
</dd>
<dt class="col-sm-5">DNS (github.com)
<span class="badge bg-success d-none" id="dns-success" title="DNS Resolving works!">Ok</span>
<span class="badge bg-danger d-none" id="dns-warning" title="DNS Resolving failed. Please fix.">Error</span>
@ -167,6 +182,14 @@
<span id="domain-server" class="d-block"><b>Server:</b> <span id="domain-server-string">{{page_data.admin_url}}</span></span>
<span id="domain-browser" class="d-block"><b>Browser:</b> <span id="domain-browser-string"></span></span>
</dd>
<dt class="col-sm-5">HTTP Response validation
<span class="badge bg-success d-none" id="http-response-success" title="All headers and HTTP request responses seem to be ok.">Ok</span>
<span class="badge bg-danger d-none" id="http-response-warning" title="Some headers or HTTP request responses return invalid data!">Error</span>
</dt>
<dd class="col-sm-7">
<span id="http-response-errors" class="d-block"></span>
</dd>
</dl>
</div>
</div>

View File

@ -19,7 +19,7 @@
<tr>
<td>
<svg width="48" height="48" class="float-start me-2 rounded" data-jdenticon-value="{{email}}">
<div class="float-start">
<div>
<strong>{{name}}</strong>
<span class="d-block">{{email}}</span>
<span class="d-block">

View File

@ -51,9 +51,11 @@ impl Fairing for AppHeaders {
}
}
// NOTE: When modifying or adding security headers be sure to also update the diagnostic checks in `src/static/scripts/admin_diagnostics.js` in `checkSecurityHeaders`
res.set_raw_header("Permissions-Policy", "accelerometer=(), ambient-light-sensor=(), autoplay=(), battery=(), camera=(), display-capture=(), document-domain=(), encrypted-media=(), execution-while-not-rendered=(), execution-while-out-of-viewport=(), fullscreen=(), geolocation=(), gyroscope=(), keyboard-map=(), magnetometer=(), microphone=(), midi=(), payment=(), picture-in-picture=(), screen-wake-lock=(), sync-xhr=(), usb=(), web-share=(), xr-spatial-tracking=()");
res.set_raw_header("Referrer-Policy", "same-origin");
res.set_raw_header("X-Content-Type-Options", "nosniff");
res.set_raw_header("X-Robots-Tag", "noindex, nofollow");
// Obsolete in modern browsers, unsafe (XS-Leak), and largely replaced by CSP
res.set_raw_header("X-XSS-Protection", "0");