Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
68 changes: 61 additions & 7 deletions backend/internal/access-list.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import utils from "../lib/utils.js";
import { access as logger } from "../logger.js";
import accessListModel from "../models/access_list.js";
import accessListAuthModel from "../models/access_list_auth.js";
import accessListClientCAsModel from "../models/access_list_clientcas.js";
import accessListClientModel from "../models/access_list_client.js";
import proxyHostModel from "../models/proxy_host.js";
import internalAuditLog from "./audit-log.js";
Expand Down Expand Up @@ -58,13 +59,25 @@ const internalAccessList = {
directive: client.directive,
});
}
for (const certificateId of data.clientcas ?? []) {
await accessListClientCAsModel.query().insert({
access_list_id: row.id,
certificate_id: certificateId,
});
}

// re-fetch with expansions
const freshRow = await internalAccessList.get(
access,
{
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.access_list.[clients,items]"],
expand: [
"owner",
"items",
"clients",
"clientcas.certificate",
"proxy_hosts.access_list.[clientcas.certificate,clients,items]",
],
},
true // skip masking
);
Expand Down Expand Up @@ -164,6 +177,16 @@ const internalAccessList = {
}
}
}
if (typeof data.clientcas !== "undefined" && data.clientcas) {
await accessListClientCAsModel.query().delete().where("access_list_id", data.id);

for (const certificateId of data.clientcas) {
await accessListClientCAsModel.query().insert({
access_list_id: data.id,
certificate_id: certificateId,
});
}
}

// Add to audit log
await internalAuditLog.add(access, {
Expand All @@ -178,7 +201,13 @@ const internalAccessList = {
access,
{
id: data.id,
expand: ["owner", "items", "clients", "proxy_hosts.[certificate,access_list.[clients,items]]"],
expand: [
"owner",
"items",
"clients",
"clientcas.certificate",
"proxy_hosts.[certificate,access_list.[clientcas.certificate,clients,items]]",
],
},
true // skip masking
);
Expand Down Expand Up @@ -217,7 +246,7 @@ const internalAccessList = {
.where("access_list.is_deleted", 0)
.andWhere("access_list.id", thisData.id)
.groupBy("access_list.id")
.allowGraph("[owner,items,clients,proxy_hosts.[certificate,access_list.[clients,items]]]")
.allowGraph("[owner,items,clients,clientcas.certificate,proxy_hosts.[certificate,access_list.[clientcas.certificate,clients,items]]]")
.first();

if (accessData.permission_visibility !== "all") {
Expand Down Expand Up @@ -254,7 +283,7 @@ const internalAccessList = {
await access.can("access_lists:delete", data.id);
const row = await internalAccessList.get(access, {
id: data.id,
expand: ["proxy_hosts", "items", "clients"],
expand: ["proxy_hosts", "items", "clients", "clientcas.certificate"],
});

if (!row?.id) {
Expand Down Expand Up @@ -333,7 +362,7 @@ const internalAccessList = {
})
.where("access_list.is_deleted", 0)
.groupBy("access_list.id")
.allowGraph("[owner,items,clients]")
.allowGraph("[owner,items,clients,clientcas.certificate]")
.orderBy("access_list.name", "ASC");

if (accessData.permission_visibility !== "all") {
Expand Down Expand Up @@ -404,6 +433,14 @@ const internalAccessList = {
return true;
});
}
if (list && typeof list.clientcas !== "undefined") {
list.clientcas.map((val, idx) => {
if (val.certificate?.meta) {
list.clientcas[idx].certificate.meta = {};
}
return true;
});
}
return list;
},

Expand All @@ -427,13 +464,19 @@ const internalAccessList = {
logger.info(`Building Access file #${list.id} for: ${list.name}`);

const htpasswdFile = internalAccessList.getFilename(list);
const clientCaFile = internalAccessList.getClientCaFilename(list);

// 1. remove any existing access file
try {
fs.unlinkSync(htpasswdFile);
} catch (_err) {
// do nothing
}
try {
fs.unlinkSync(clientCaFile);
} catch (_err) {
// do nothing
}

// 2. create empty access file
fs.writeFileSync(htpasswdFile, '', {encoding: 'utf8'});
Expand Down Expand Up @@ -471,7 +514,18 @@ const internalAccessList = {
});
});
}
}
}

fs.writeFileSync(clientCaFile, "", { encoding: "utf8" });
for (const clientca of list.clientcas ?? []) {
if (clientca.certificate?.meta?.certificate) {
fs.appendFileSync(clientCaFile, `${clientca.certificate.meta.certificate}\n`, { encoding: "utf8" });
}
}
},

getClientCaFilename: (list) => {
return `/data/clientca/${list.id}`;
},
};

export default internalAccessList;
49 changes: 36 additions & 13 deletions backend/internal/certificate.js
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,12 @@ const internalCertificate = {
});
}).then(() => {
return new Promise((resolve, reject) => {
if (certificate.provider === "clientca") {
// Client CAs have no private key associated, so just succeed.
resolve();
return;
}

fs.writeFile(`${dir}/privkey.pem`, certificate.meta.certificate_key, (err) => {
if (err) {
reject(err);
Expand Down Expand Up @@ -596,7 +602,7 @@ const internalCertificate = {
*/
upload: async (access, data) => {
const row = await internalCertificate.get(access, { id: data.id });
if (row.provider !== "other") {
if (row.provider !== "other" && row.provider !== "clientca") {
throw new error.ValidationError("Cannot upload certificates for this type of provider");
}

Expand Down Expand Up @@ -671,6 +677,26 @@ const internalCertificate = {
}
},

/**
* Parse the X509 subject line as returned by the OpenSSL command when
* invoked with openssl x509 -in <certificate name> -subject -noout
*
* @param {String} line emitted from the openssl command
* @param {String} prefix expected to be removed
* @return {Object} object containing the parsed fields from the subject line
*/
parseX509Output: (line, prefix) => {
const subjectValue = line.trim().slice(prefix.length);

return subjectValue
.split(/,(?=(?:(?:[^"]*"){2})*[^"]*$)/)
.map((e) => e.trim().split(/\s*=\s*/, 2))
.reduce((obj, [key, value]) => {
obj[key] = value.replace(/^"/, "").replace(/"$/, "");
return obj;
}, {});
},

/**
* Uses the openssl command to both validate and get info out of the certificate.
* It will save the file to disk first, then run commands on it, then delete the file.
Expand All @@ -685,25 +711,22 @@ const internalCertificate = {
const result = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-subject", "-noout"]);

// Examples:
// subject=CN = *.jc21.com
// subject=CN = something.example.com
// subject=CN=*.jc21.com
const regex = /(?:subject=)?[^=]+=\s*(\S+)/gim;
const match = regex.exec(result);
if (match && typeof match[1] !== "undefined") {
certData.cn = match[1].trim();
// subject=C = NoCountry, O = NoOrg, OU = NoOrgUnit, CN = Some Value With Spaces
const subjectParams = internalCertificate.parseX509Output(result, "subject=");
if (typeof subjectParams.CN === "undefined") {
throw new error.ValidationError(`Could not determine subject from certificate: ${result}`);
}
certData.cn = subjectParams.CN;

const result2 = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-issuer", "-noout"]);
// Examples:
// issuer=C = US, O = Let's Encrypt, CN = Let's Encrypt Authority X3
// issuer=C = US, O = Let's Encrypt, CN = E5
// issuer=O = NginxProxyManager, CN = NginxProxyManager Intermediate CA","O = NginxProxyManager, CN = NginxProxyManager Intermediate CA
const regex2 = /^(?:issuer=)?(.*)$/gim;
const match2 = regex2.exec(result2);
if (match2 && typeof match2[1] !== "undefined") {
certData.issuer = match2[1];
const issuerParams = internalCertificate.parseX509Output(result2, "issuer=");
if (typeof issuerParams.CN === "undefined") {
throw new error.ValidationError(`Could not determine issuer from certificate: ${result2}`);
}
certData.issuer = issuerParams.CN;

const result3 = await utils.execFile("openssl", ["x509", "-in", certificateFile, "-dates", "-noout"]);
// notBefore=Jul 14 04:04:29 2018 GMT
Expand Down
6 changes: 6 additions & 0 deletions backend/internal/nginx.js
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ const internalNginx = {
{ ssl_forced: host.ssl_forced },
{ caching_enabled: host.caching_enabled },
{ block_exploits: host.block_exploits },
{ drop_unauthorized: host.drop_unauthorized },
{ allow_websocket_upgrade: host.allow_websocket_upgrade },
{ http2_support: host.http2_support },
{ hsts_enabled: host.hsts_enabled },
Expand Down Expand Up @@ -209,6 +210,11 @@ const internalNginx = {
let origLocations;

// Manipulate the data a bit before sending it to the template
if (typeof host.drop_unauthorized === "undefined") {
// Only proxy hosts expose this field, but hosts share templates.
host.drop_unauthorized = 0;
}

if (nice_host_type !== "default") {
host.use_default_location = true;
if (typeof host.advanced_config !== "undefined" && host.advanced_config) {
Expand Down
6 changes: 3 additions & 3 deletions backend/internal/proxy-host.js
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ const internalProxyHost = {
// re-fetch with cert
return internalProxyHost.get(access, {
id: row.id,
expand: ["certificate", "owner", "access_list.[clients,items]"],
expand: ["certificate", "owner", "access_list.[clientcas.certificate,clients,items]"],
});
})
.then((row) => {
Expand Down Expand Up @@ -206,7 +206,7 @@ const internalProxyHost = {
return internalProxyHost
.get(access, {
id: thisData.id,
expand: ["owner", "certificate", "access_list.[clients,items]"],
expand: ["owner", "certificate", "access_list.[clientcas.certificate,clients,items]"],
})
.then((row) => {
if (!row.enabled) {
Expand Down Expand Up @@ -323,7 +323,7 @@ const internalProxyHost = {
.then(() => {
return internalProxyHost.get(access, {
id: data.id,
expand: ["certificate", "owner", "access_list"],
expand: ["certificate", "owner", "access_list.[clientcas.certificate,clients,items]"],
});
})
.then((row) => {
Expand Down
52 changes: 52 additions & 0 deletions backend/migrations/20230526062132_add_clientcas_to_accesslists.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { migrate as logger } from "../logger.js";

const migrateName = "client_certificates";

/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);

return knex.schema.createTable("access_list_clientcas", (table) => {
table.increments().primary();
table.dateTime("created_on").notNull();
table.dateTime("modified_on").notNull();
table.integer("access_list_id").notNull().unsigned();
table.integer("certificate_id").notNull().unsigned();
table.json("meta").notNull();
})
.then(() => {
logger.info(`[${migrateName}] access_list_clientcas Table created`);
})
.then(() => {
logger.info(`[${migrateName}] Migrating Up Complete`);
});
};

/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);

return knex.schema.dropTable("access_list_clientcas")
.then(() => {
logger.info(`[${migrateName}] access_list_clientcas Table dropped`);
})
.then(() => {
logger.info(`[${migrateName}] Migrating Down Complete`);
});
};

export { up, down };
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { migrate as logger } from "../logger.js";

const migrateName = "drop_unauthorized";

/**
* Migrate
*
* @see http://knexjs.org/#Schema
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
const up = (knex) => {
logger.info(`[${migrateName}] Migrating Up...`);

return knex.schema.table("proxy_host", (table) => {
table.integer("drop_unauthorized").notNull().unsigned().defaultTo(0);
}).then(() => {
logger.info(`[${migrateName}] Migrating Up Complete`);
});
};

/**
* Undo Migrate
*
* @param {Object} knex
* @param {Promise} Promise
* @returns {Promise}
*/
const down = (knex) => {
logger.info(`[${migrateName}] Migrating Down...`);

return knex.schema.table("proxy_host", (table) => {
table.dropColumn("drop_unauthorized");
}).then(() => {
logger.info(`[${migrateName}] Migrating Down Complete`);
});
};

export { up, down };
9 changes: 9 additions & 0 deletions backend/models/access_list.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import db from "../db.js";
import { convertBoolFieldsToInt, convertIntFieldsToBool } from "../lib/helpers.js";
import AccessListAuth from "./access_list_auth.js";
import AccessListClient from "./access_list_client.js";
import AccessListClientCAs from "./access_list_clientcas.js";
import now from "./now_helper.js";
import ProxyHostModel from "./proxy_host.js";
import User from "./user.js";
Expand Down Expand Up @@ -80,6 +81,14 @@ class AccessList extends Model {
to: "access_list_client.access_list_id",
},
},
clientcas: {
relation: Model.HasManyRelation,
modelClass: AccessListClientCAs,
join: {
from: "access_list.id",
to: "access_list_clientcas.access_list_id",
},
},
proxy_hosts: {
relation: Model.HasManyRelation,
modelClass: ProxyHostModel,
Expand Down
Loading