Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ insert_permissions:
- is_default
- nav_group
- nav_order
- deployments
comment: ""
select_permissions:
- role: administrator
Expand All @@ -34,6 +35,7 @@ select_permissions:
- is_default
- nav_group
- nav_order
- deployments
- created_at
- updated_at
filter: {}
Expand Down Expand Up @@ -247,6 +249,7 @@ update_permissions:
- is_default
- nav_group
- nav_order
- deployments
filter: {}
check: {}
comment: ""
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
ALTER TABLE "public"."custom_pages" DROP COLUMN IF EXISTS "deployments";
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
ALTER TABLE "public"."custom_pages"
ADD COLUMN IF NOT EXISTS "deployments" jsonb NOT NULL DEFAULT '[]'::jsonb;
26 changes: 26 additions & 0 deletions src/custom-pages/custom-pages.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Request, Response } from "express";
import { User } from "../auth/types/User";
import { isRoleAbove } from "src/utilities/isRoleAbove";
import { e_player_roles_enum } from "generated";
import { SystemService } from "src/system/system.service";

@Controller("custom-pages")
export class CustomPagesController {
Expand Down Expand Up @@ -105,6 +106,9 @@ export class CustomPagesController {
scope: manifest.scope ?? null,
module: manifest.module ?? manifest.exposedModule ?? null,
requiredRole: manifest.requiredRole ?? null,
deployments: CustomPagesController.resolveDeployments(
manifest.deployments,
),
});
} catch {
return response
Expand All @@ -113,6 +117,28 @@ export class CustomPagesController {
}
}

// Deployments the plugin wants watched for image updates. The panel restarts
// whatever is named here, and the manifest is third-party input, so reserved
// first-party names are dropped -- otherwise a plugin could declare "api" and
// get the panel to roll itself. Invalid entries are dropped rather than
// failing the detect, so one typo doesn't block registering the plugin.
private static resolveDeployments(value: unknown): string[] {
if (!Array.isArray(value)) {
return [];
}

return value
.filter((name): name is string => {
return (
typeof name === "string" &&
name.length <= 63 &&
/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/.test(name) &&
!SystemService.isReservedDeployment(name)
);
})
.slice(0, 8);
}

// Best-effort SSRF guard for the admin-only detect fetch: blocks loopback,
// link-local, private-range IP literals and obvious internal hostnames.
// Hostnames resolving to private IPs are not caught -- admins are trusted;
Expand Down
103 changes: 103 additions & 0 deletions src/system/system.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { SystemService } from "./system.service";

describe("SystemService.parseImageRef", () => {
// The first-party services this has always had to handle, plus the shapes a
// third-party plugin can realistically ship. Getting the registry/repository
// split wrong sends the manifest request to the wrong host and every plugin
// silently reports "no update available".
const vectors: Array<{
image: string;
registry: string;
repository: string;
tag: string;
}> = [
{
image: "ghcr.io/5stackgg/api:latest",
registry: "ghcr.io",
repository: "5stackgg/api",
tag: "latest",
},
// A plugin published under someone else's org -- the case the old
// hardcoded `5stackgg` path could not express at all.
{
image: "ghcr.io/lukepolo/5stack-inventory-plugin-frontend:latest",
registry: "ghcr.io",
repository: "lukepolo/5stack-inventory-plugin-frontend",
tag: "latest",
},
// The deployed tag is the release channel; beta must not collapse to latest.
{
image: "ghcr.io/5stackgg/web:beta",
registry: "ghcr.io",
repository: "5stackgg/web",
tag: "beta",
},
{
image: "docker.io/library/nginx:1.27",
registry: "docker.io",
repository: "library/nginx",
tag: "1.27",
},
// No registry host and no tag: Docker Hub official image, implicit latest.
{
image: "nginx",
registry: "docker.io",
repository: "library/nginx",
tag: "latest",
},
// Bare namespaced image is Hub too -- "myorg" has no dot, so it is not a host.
{
image: "myorg/myimage:v2",
registry: "docker.io",
repository: "myorg/myimage",
tag: "v2",
},
// A port in the host means the first segment IS the registry, and the colon
// in it must not be mistaken for the tag separator.
{
image: "registry.local:5000/team/app:dev",
registry: "registry.local:5000",
repository: "team/app",
tag: "dev",
},
{
image: "registry.local:5000/team/app",
registry: "registry.local:5000",
repository: "team/app",
tag: "latest",
},
];

for (const { image, ...expected } of vectors) {
it(`parses ${image}`, () => {
expect(SystemService.parseImageRef(image)).toEqual(expected);
});
}

// A digest-pinned image already names exact bytes, so there is nothing to
// poll -- returning a ref would make us compare a digest against itself.
it.each(["ghcr.io/5stackgg/api@sha256:abc123", "", null, undefined])(
"returns null for %p",
(image) => {
expect(SystemService.parseImageRef(image as string)).toBeNull();
},
);
});

describe("SystemService.isReservedDeployment", () => {
// Plugin manifests are third-party input. If these names were claimable, a
// plugin could get the panel to restart the panel.
it.each(["api", "web", "hasura", "panel", "redis", "timescaledb"])(
"reserves %s",
(name) => {
expect(SystemService.isReservedDeployment(name)).toBe(true);
},
);

it.each(["inventory-frontend", "inventory-backend", "example-plugin"])(
"allows %s",
(name) => {
expect(SystemService.isReservedDeployment(name)).toBe(false);
},
);
});
Loading
Loading