diff --git a/backend/internal/dead-host.js b/backend/internal/dead-host.js index c8e0e17cd..41291ed79 100644 --- a/backend/internal/dead-host.js +++ b/backend/internal/dead-host.js @@ -54,10 +54,7 @@ const internalDeadHost = { thisData.advanced_config = ""; } - const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin"); - if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } + internalHost.assertAdvancedConfigAllowed(access, thisData); const row = await deadHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); @@ -156,14 +153,7 @@ const internalDeadHost = { thisData = internalHost.cleanSslHstsData(thisData, row); - const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin"); - if ( - !isAdmin && - typeof data.advanced_config !== "undefined" && - data.advanced_config !== row.advanced_config - ) { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } + internalHost.assertAdvancedConfigAllowed(access, data, row); // do the row update await deadHostModel.query().where({ id: data.id }).patch(data); diff --git a/backend/internal/host.js b/backend/internal/host.js index 81efe160e..eb871a192 100644 --- a/backend/internal/host.js +++ b/backend/internal/host.js @@ -1,10 +1,98 @@ import _ from "lodash"; +import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import deadHostModel from "../models/dead_host.js"; import proxyHostModel from "../models/proxy_host.js"; import redirectionHostModel from "../models/redirection_host.js"; const internalHost = { + /** + * Only admins may set or change advanced_config (raw nginx config). Throws for anyone else. + * On create (no `row`), any non-empty advanced_config is refused, including on locations. + * On update, the host's advanced_config must be unchanged (or omitted), and locations must + * only carry advanced configs the host already has (see assertLocationAdvancedConfigUnchanged). + * + * @param {Access} access + * @param {Object} data incoming host data; data.locations may be filled in on update + * @param {Object} [row] the existing host, when updating + * @throws {PermissionError} + */ + assertAdvancedConfigAllowed: (access, data, row) => { + if (access.isAdmin?.()) { + return; + } + const deny = () => { + throw new errs.PermissionError("You do not have permission to modify advanced configuration"); + }; + + if (!row) { + const hasConfig = (v) => typeof v === "string" && v.trim() !== ""; + if (hasConfig(data.advanced_config)) { + deny(); + } + if (Array.isArray(data.locations) && data.locations.some((loc) => hasConfig(loc?.advanced_config))) { + deny(); + } + return; + } + + if (typeof data.advanced_config !== "undefined" && data.advanced_config !== row.advanced_config) { + deny(); + } + if (Array.isArray(data.locations)) { + internalHost.assertLocationAdvancedConfigUnchanged(data.locations, row.locations); + } + }, + + /** + * For non-admins: every location's advanced_config must be one that already exists on this + * host. Matching is by value, not path, so renaming or reordering a location that carries + * admin-written config is allowed, but new or edited config is not. Each existing config can + * be used once (no duplicating it onto other paths), and same-path matches are taken first. + * A location that omits advanced_config keeps the one at its path. Mutates `locations`. + * + * @param {Array} locations incoming locations + * @param {Array} [existingLocations] the host's current locations + * @throws {PermissionError} + */ + assertLocationAdvancedConfigUnchanged: (locations, existingLocations) => { + const existing = Array.isArray(existingLocations) ? existingLocations : []; + const advAtPath = (path) => existing.find((l) => l.path === path)?.advanced_config || ""; + const pool = existing.map((l) => l.advanced_config || "").filter(Boolean); + const take = (value) => { + const i = pool.indexOf(value); + if (i === -1) { + return false; + } + pool.splice(i, 1); + return true; + }; + const deny = () => { + throw new errs.PermissionError("You do not have permission to modify advanced configuration"); + }; + + // Pass 1: locations that keep (or omit) the config at their own path + const unresolved = []; + for (const loc of locations) { + const atPath = advAtPath(loc.path); + if (typeof loc.advanced_config === "undefined") { + loc.advanced_config = atPath && take(atPath) ? atPath : ""; + } else if ((loc.advanced_config || "") === atPath && (!atPath || take(atPath))) { + // unchanged + } else { + unresolved.push(loc); + } + } + + // Pass 2: renamed/moved locations may carry an existing config not already claimed. + // Clearing a config is not allowed, as before. + for (const loc of unresolved) { + if (!loc.advanced_config || !take(loc.advanced_config)) { + deny(); + } + } + }, + /** * Makes sure that the ssl_* and hsts_* fields play nicely together. * ie: if there is no cert, then force_ssl is off. diff --git a/backend/internal/proxy-host.js b/backend/internal/proxy-host.js index 587535c2a..b148b327a 100644 --- a/backend/internal/proxy-host.js +++ b/backend/internal/proxy-host.js @@ -2,7 +2,7 @@ import _ from "lodash"; import errs from "../lib/error.js"; import { castJsonIfNeed } from "../lib/helpers.js"; import utils from "../lib/utils.js"; -import proxyHostModel from "../models/proxy_host.js"; +import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js"; import userPermissionModel from "../models/user_permission.js"; import internalAuditLog from "./audit-log.js"; import internalCertificate from "./certificate.js"; @@ -14,55 +14,6 @@ const omissions = () => { }; const internalProxyHost = { - /** - * For non-admins: every location's advanced_config must be one that already exists on this - * host. Matching is by value, not path, so renaming or reordering a location that carries - * admin-written config is allowed, but new or edited config is not. Each existing config can - * be used once (no duplicating it onto other paths), and same-path matches are taken first. - * A location that omits advanced_config keeps the one at its path. Mutates `locations`. - * - * @param {Array} locations incoming locations - * @param {Array} [existingLocations] the host's current locations - * @throws {PermissionError} - */ - assertLocationAdvancedConfigUnchanged: (locations, existingLocations) => { - const existing = Array.isArray(existingLocations) ? existingLocations : []; - const advAtPath = (path) => existing.find((l) => l.path === path)?.advanced_config || ""; - const pool = existing.map((l) => l.advanced_config || "").filter(Boolean); - const take = (value) => { - const i = pool.indexOf(value); - if (i === -1) { - return false; - } - pool.splice(i, 1); - return true; - }; - const deny = () => { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - }; - - // Pass 1: locations that keep (or omit) the config at their own path - const unresolved = []; - for (const loc of locations) { - const atPath = advAtPath(loc.path); - if (typeof loc.advanced_config === "undefined") { - loc.advanced_config = atPath && take(atPath) ? atPath : ""; - } else if ((loc.advanced_config || "") === atPath && (!atPath || take(atPath))) { - // unchanged - } else { - unresolved.push(loc); - } - } - - // Pass 2: renamed/moved locations may carry an existing config not already claimed. - // Clearing a config is not allowed, as before. - for (const loc of unresolved) { - if (!loc.advanced_config || !take(loc.advanced_config)) { - deny(); - } - } - }, - /** * @param {Access} access * @param {Object} data @@ -107,17 +58,7 @@ const internalProxyHost = { thisData.advanced_config = ""; } - const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin"); - if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } - if ( - !isAdmin && - Array.isArray(thisData.locations) && - thisData.locations.some((loc) => loc?.advanced_config && loc.advanced_config.trim() !== "") - ) { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } + internalHost.assertAdvancedConfigAllowed(access, thisData); return proxyHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); }) @@ -245,17 +186,7 @@ const internalProxyHost = { thisData = internalHost.cleanSslHstsData(thisData, row); - const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin"); - if ( - !isAdmin && - typeof thisData.advanced_config !== "undefined" && - thisData.advanced_config !== row.advanced_config - ) { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } - if (!isAdmin && Array.isArray(thisData.locations)) { - internalProxyHost.assertLocationAdvancedConfigUnchanged(thisData.locations, row.locations); - } + internalHost.assertAdvancedConfigAllowed(access, thisData, row); return proxyHostModel .query() @@ -317,14 +248,7 @@ const internalProxyHost = { .first(); if (access_data.permission_visibility !== "all") { - const permissions = access.getPermissions ? access.getPermissions() : {}; - const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || []; - query.andWhere((builder) => { - builder.where("owner_user_id", access.token.getUserId(1)); - if (allowedIds.length > 0) { - builder.orWhereIn("id", allowedIds); - } - }); + scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta); } if (typeof thisData.expand !== "undefined" && thisData.expand !== null) { @@ -508,14 +432,7 @@ const internalProxyHost = { .orderBy(castJsonIfNeed("domain_names"), "ASC"); if (accessData.permission_visibility !== "all") { - const permissions = access.getPermissions ? access.getPermissions() : {}; - const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || []; - query.andWhere((builder) => { - builder.where("owner_user_id", access.token.getUserId(1)); - if (allowedIds.length > 0) { - builder.orWhereIn("id", allowedIds); - } - }); + scopeToOwnerOrAllowed(query, access.token.getUserId(1), access.getPermissions?.()?.meta); } // Query is used for searching @@ -548,13 +465,7 @@ const internalProxyHost = { if (visibility !== "all") { const perm = await userPermissionModel.query().where("user_id", user_id).first(); - const allowedIds = perm?.meta?.proxy_host_ids || perm?.meta?.proxyHostIds || []; - query.andWhere((builder) => { - builder.where("owner_user_id", user_id); - if (allowedIds.length > 0) { - builder.orWhereIn("id", allowedIds); - } - }); + scopeToOwnerOrAllowed(query, user_id, perm?.meta); } const row = await query.first(); diff --git a/backend/internal/redirection-host.js b/backend/internal/redirection-host.js index 5e0f3a260..f7ad6be33 100644 --- a/backend/internal/redirection-host.js +++ b/backend/internal/redirection-host.js @@ -57,10 +57,7 @@ const internalRedirectionHost = { thisData.advanced_config = ""; } - const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin"); - if (!isAdmin && thisData.advanced_config && thisData.advanced_config.trim() !== "") { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } + internalHost.assertAdvancedConfigAllowed(access, thisData); return redirectionHostModel.query().insertAndFetch(thisData).then(utils.omitRow(omissions())); }) @@ -188,14 +185,7 @@ const internalRedirectionHost = { thisData = internalHost.cleanSslHstsData(thisData, row); - const isAdmin = access.hasRole?.("admin") || access.token.hasScope("admin"); - if ( - !isAdmin && - typeof thisData.advanced_config !== "undefined" && - thisData.advanced_config !== row.advanced_config - ) { - throw new errs.PermissionError("You do not have permission to modify advanced configuration"); - } + internalHost.assertAdvancedConfigAllowed(access, thisData, row); return redirectionHostModel .query() diff --git a/backend/lib/access.js b/backend/lib/access.js index 3e27ff388..c38213b33 100644 --- a/backend/lib/access.js +++ b/backend/lib/access.js @@ -13,7 +13,7 @@ import Ajv from "ajv/dist/2020.js"; import _ from "lodash"; import { access as logger } from "../logger.js"; import authModel from "../models/auth.js"; -import proxyHostModel from "../models/proxy_host.js"; +import proxyHostModel, { scopeToOwnerOrAllowed } from "../models/proxy_host.js"; import TokenModel from "../models/token.js"; import userModel from "../models/user.js"; import permsSchema from "./access/permissions.json" with { type: "json" }; @@ -139,13 +139,7 @@ export default function (tokenString) { const query = proxyHostModel.query().select("id").andWhere("is_deleted", 0); if (permissions.visibility === "user") { - const allowedIds = permissions.meta?.proxy_host_ids || permissions.meta?.proxyHostIds || []; - query.andWhere((builder) => { - builder.where("owner_user_id", tokenUserId); - if (allowedIds.length > 0) { - builder.orWhereIn("id", allowedIds); - } - }); + scopeToOwnerOrAllowed(query, tokenUserId, permissions.meta); } const rows = await query; @@ -222,6 +216,7 @@ export default function (tokenString) { getPermissions: () => permissions, getRoles: () => userRoles, hasRole: (role) => userRoles.includes(role), + isAdmin: () => userRoles.includes("admin") || Token.hasScope("admin"), /** * diff --git a/backend/models/proxy_host.js b/backend/models/proxy_host.js index acb8da935..d16ddc912 100644 --- a/backend/models/proxy_host.js +++ b/backend/models/proxy_host.js @@ -124,4 +124,23 @@ class ProxyHost extends Model { } } +/** + * Restricts a proxy host query to hosts the user owns or has been granted by id + * (user permission meta.proxy_host_ids). + * + * @param {Object} query objection query on proxy_host + * @param {Number} userId + * @param {Object} [permissionMeta] the user's permission meta + * @returns {Object} the query + */ +export const scopeToOwnerOrAllowed = (query, userId, permissionMeta) => { + const allowedIds = permissionMeta?.proxy_host_ids || []; + return query.andWhere((builder) => { + builder.where("owner_user_id", userId); + if (allowedIds.length > 0) { + builder.orWhereIn("id", allowedIds); + } + }); +}; + export default ProxyHost;