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
5 changes: 5 additions & 0 deletions .changeset/integration-manifest-limit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@openmapx/extension-cli": patch
---

`openmapx-ext package` refuses a `manifest.json` larger than 128 KiB, the most the OpenMapX operations agent reads. An artifact with a larger manifest could not be installed.
12 changes: 12 additions & 0 deletions docs/docs/administration/community-extensions.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,18 @@ entry points are also rejected; executable behavior must move into an isolated
service. These reduce the blast radius of a
malformed archive. They cannot establish that the author's code is safe.

The operations agent reads the configuration schema of every installed
integration, and loads one only from files its own account owns and nobody
else can write. A single integration it refuses stops it from authorizing any
configuration change. The installer therefore gives installed files fixed
modes (`0755` for directories, `0644` for files), whatever the archive or the
umask declares. It also refuses an integration the agent would not load:

- a `manifest.json` that is a symbolic link, hard-linked to another file,
larger than 128 KiB or not UTF-8;
- a configuration schema the agent does not accept;
- an id longer than 64 characters.

### Preview, admission and failures

Extension previews have an independent limit of one request per 30 seconds per
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/developer/building-an-external-extension.md
Original file line number Diff line number Diff line change
Expand Up @@ -272,7 +272,7 @@ cannot reach a release:

| Included | Limit |
| ------------------------------------------------------------------ | ----------------------- |
| `manifest.json` (after schema validation) | : |
| `manifest.json` (after schema validation) | 128 KiB |
| `openmapx-artifact.json` (generated) | : |
| `dist/licenses.json` (generated when dependency notices are found) | : |
| `strings/<locale>.json` matching `[a-z0-9-]{2,35}` | 100 files, 256 KiB each |
Expand Down
194 changes: 192 additions & 2 deletions packages/cli/__tests__/cli-integrations.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { spawnSync } from "node:child_process";
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
linkSync,
lstatSync,
mkdirSync,
mkdtempSync,
Expand All @@ -15,6 +17,7 @@ import {
import { tmpdir } from "node:os";
import { join } from "node:path";
import { gzipSync } from "node:zlib";
import { services } from "@openmapx/core/server";
import {
backupInstalledIntegration,
packageIntegration,
Expand Down Expand Up @@ -62,7 +65,8 @@ function writeTarGz(sourceDir: string, artifactPath: string): void {

interface TarFixtureEntry {
name: string;
type?: "0" | "2" | "7" | "g" | "x";
type?: "0" | "1" | "2" | "5" | "7" | "g" | "x";
mode?: number;
data?: Buffer | string;
linkName?: string;
claimedSize?: number;
Expand All @@ -79,7 +83,7 @@ function tarFixtureEntry(entry: TarFixtureEntry): Buffer {
const claimedSize = entry.claimedSize ?? payload.length;
const header = Buffer.alloc(512);
header.write(entry.name, 0, 100, "utf-8");
writeTarOctal(header, 100, 8, 0o644);
writeTarOctal(header, 100, 8, entry.mode ?? 0o644);
writeTarOctal(header, 108, 8, 0);
writeTarOctal(header, 116, 8, 0);
writeTarOctal(header, 124, 12, claimedSize);
Expand Down Expand Up @@ -923,6 +927,192 @@ describe("packageIntegration", () => {
});
});

describe("installIntegration (what the operations agent loads)", () => {
const schemaManifest = (id: string, extra: Record<string, unknown> = {}) =>
JSON.stringify({
...baseManifest,
id,
configSchema: { type: "object", properties: { region: { enum: ["eu", "us"] } } },
...extra,
});
const install = (source: string, sourceKind: "artifact" | "source" = "artifact") =>
installIntegration({ source, sourceKind, rootDir: tmp });
/** The integration schemas the agent's loader returns, or its error. */
const agentLoads = () => [...services.loadTrustedIntegrationSchemas(tmp).keys()];
const mode = (path: string) => lstatSync(path).mode & 0o777;

/** A Debian or Ubuntu login shell's umask while `run` runs. */
async function underUmask002<T>(run: () => Promise<T>): Promise<T> {
const previous = process.umask(0o002);
try {
return await run();
} finally {
process.umask(previous);
}
}

async function installKept(): Promise<void> {
const artifact = join(tmp, "kept.tar.gz");
writeTarFixture(artifact, [{ name: "manifest.json", data: schemaManifest("kept") }]);
await install(artifact);
}

it("installs an archive declaring 0666 and 0664 modes with the modes the agent loads", async () => {
const artifact = join(tmp, "loose-modes.tar.gz");
writeTarFixture(artifact, [
{ name: "assets/", type: "5", mode: 0o777 },
{ name: "manifest.json", data: schemaManifest("loose-modes"), mode: 0o666 },
{ name: "strings/en.json", data: "{}", mode: 0o664 },
{ name: "assets/data.json", data: "{}", mode: 0o000 },
]);
await underUmask002(() => install(artifact));

const installed = join(tmp, "custom_integrations", "loose-modes");
expect(mode(join(tmp, "custom_integrations"))).toBe(0o755);
expect(mode(installed)).toBe(0o755);
expect(mode(join(installed, "manifest.json"))).toBe(0o644);
expect(mode(join(installed, "strings"))).toBe(0o755);
expect(mode(join(installed, "strings", "en.json"))).toBe(0o644);
expect(mode(join(installed, "assets"))).toBe(0o755);
expect(mode(join(installed, "assets", "data.json"))).toBe(0o644);
expect(agentLoads()).toEqual(["loose-modes"]);
});

it("installs a local source written under umask 002 with the modes the agent loads", async () => {
const source = join(tmp, "local-loose");
mkdirSync(join(source, "strings"), { recursive: true });
writeFileSync(join(source, "manifest.json"), schemaManifest("local-loose"));
writeFileSync(join(source, "strings", "en.json"), "{}");
chmodSync(join(source, "manifest.json"), 0o664);
chmodSync(join(source, "strings"), 0o775);
await underUmask002(() => install(source, "source"));

const installed = join(tmp, "custom_integrations", "local-loose");
expect(mode(installed)).toBe(0o755);
expect(mode(join(installed, "manifest.json"))).toBe(0o644);
expect(mode(join(installed, "strings"))).toBe(0o755);
expect(agentLoads()).toEqual(["local-loose"]);
});

it("refuses a hard-linked manifest.json and keeps the installed integrations loadable", async () => {
await installKept();
const linkedTo = join(tmp, "manifest-linked-to.tar.gz");
writeTarFixture(linkedTo, [
{ name: "copy.json", data: schemaManifest("linked-to") },
{ name: "manifest.json", type: "1", linkName: "copy.json" },
]);
await expectArtifactRejection(linkedTo, "linked-to", /must not be hard-linked/);
const linkedFrom = join(tmp, "manifest-linked-from.tar.gz");
writeTarFixture(linkedFrom, [
{ name: "manifest.json", data: schemaManifest("linked-from") },
{ name: "alias.json", type: "1", linkName: "manifest.json" },
]);
await expectArtifactRejection(linkedFrom, "linked-from", /must not be hard-linked/);
expect(agentLoads()).toEqual(["kept"]);
});

it("refuses a symlinked manifest.json from an archive or a local source", async () => {
await installKept();
const artifact = join(tmp, "manifest-symlink.tar.gz");
writeTarFixture(artifact, [
{ name: "real.json", data: schemaManifest("manifest-symlink") },
{ name: "manifest.json", type: "2", linkName: "real.json" },
]);
await expectArtifactRejection(artifact, "manifest-symlink", /not a symbolic link/);

const source = join(tmp, "local-symlink");
mkdirSync(source);
writeFileSync(join(source, "real.json"), schemaManifest("local-symlink"));
symlinkSync("real.json", join(source, "manifest.json"));
await expect(install(source, "source")).rejects.toThrow(/not a symbolic link/);
expect(existsSync(join(tmp, "custom_integrations", "local-symlink"))).toBe(false);
expect(agentLoads()).toEqual(["kept"]);
});

it("installs a local source's hard-linked manifest.json as a file of its own", async () => {
// A local source is copied file by file, so no link survives the copy.
const source = join(tmp, "local-hardlink");
mkdirSync(source);
writeFileSync(join(source, "manifest.json"), schemaManifest("local-hardlink"));
linkSync(join(source, "manifest.json"), join(source, "alias.json"));
await install(source, "source");
const installed = join(tmp, "custom_integrations", "local-hardlink", "manifest.json");
expect(lstatSync(installed).nlink).toBe(1);
expect(agentLoads()).toEqual(["local-hardlink"]);
});

/** A manifest whose description holds a byte that is not UTF-8. */
function invalidUtf8Manifest(id: string): Buffer {
const [head, tail] = schemaManifest(id, { description: "X" }).split('"X"');
return Buffer.concat([Buffer.from(`${head}"`), Buffer.from([0xff]), Buffer.from(`"${tail}`)]);
}

it("refuses manifests the agent's loader would reject", async () => {
await installKept();
const cases: Array<[string, Buffer | string, RegExp]> = [
[
"oversized",
`${schemaManifest("oversized")}${" ".repeat(services.MAX_INTEGRATION_MANIFEST_BYTES)}`,
/exceeds the 128 KiB limit/,
],
[
"unaccepted-schema",
schemaManifest("unaccepted-schema", {
configSchema: {
type: "object",
properties: { region: { type: "string", examples: ["eu"] } },
},
}),
/configSchema uses a construct/,
],
["invalid-utf8", invalidUtf8Manifest("invalid-utf8"), /not valid UTF-8/],
[`a${"b".repeat(64)}`, schemaManifest(`a${"b".repeat(64)}`), /longer than the 64 characters/],
];
for (const [id, data, message] of cases) {
const artifact = join(tmp, "refused.tar.gz");
writeTarFixture(artifact, [{ name: "manifest.json", data }]);
await expectArtifactRejection(artifact, id, message);
}
expect(agentLoads()).toEqual(["kept"]);
});

it("takes over a group-writable custom_integrations it owns", async () => {
mkdirSync(join(tmp, "custom_integrations"));
chmodSync(join(tmp, "custom_integrations"), 0o775);
await installKept();
expect(mode(join(tmp, "custom_integrations"))).toBe(0o755);
expect(agentLoads()).toEqual(["kept"]);
});

it("restores a rollback backup with the modes the agent loads", async () => {
await installKept();
const target = join(tmp, "custom_integrations", "kept");
const backup = backupInstalledIntegration(tmp, "kept") as NonNullable<
ReturnType<typeof backupInstalledIntegration>
>;
chmodSync(join(backup.backupDirectory, "manifest.json"), 0o666);
chmodSync(backup.backupDirectory, 0o777);
restoreInstalledIntegration(tmp, backup);
expect(mode(target)).toBe(0o755);
expect(mode(join(target, "manifest.json"))).toBe(0o644);
expect(agentLoads()).toEqual(["kept"]);
});
});

describe("packageIntegration (what the operations agent loads)", () => {
it("refuses a manifest larger than the agent reads", async () => {
const source = join(tmp, "oversized-source");
mkdirSync(source);
writeFileSync(
join(source, "manifest.json"),
`${JSON.stringify({ ...baseManifest, id: "oversized-source" })}${" ".repeat(services.MAX_INTEGRATION_MANIFEST_BYTES)}`,
);
await expect(
packageIntegration({ rootDir: tmp, source, outFile: join(tmp, "out.tar.gz") }),
).rejects.toThrow(/manifest\.json exceeds its 131072-byte limit/);
});
});

describe("integration rollback backups", () => {
it("restores the exact pre-update files", () => {
writeIntegration("custom_integrations", "weather-radar", {
Expand Down
8 changes: 8 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,14 @@
"types": "./src/services/secret-key.ts",
"default": "./src/services/secret-key.ts"
},
"./services/configuration-schema": {
"types": "./src/services/configuration-schema.ts",
"default": "./src/services/configuration-schema.ts"
},
"./services/trusted-integration-schemas": {
"types": "./src/services/trusted-integration-schemas.ts",
"default": "./src/services/trusted-integration-schemas.ts"
},
"./utils/brandFilter": {
"types": "./src/utils/brandFilter.ts",
"default": "./src/utils/brandFilter.ts"
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/services/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ export {
type TrustedAuthorityReadHooks,
} from "./trusted-authority-filesystem";
export {
assertTrustedIntegrationDirectory,
type LoadTrustedIntegrationSchemasOptions,
loadTrustedIntegrationSchemas,
MAX_INTEGRATION_MANIFEST_BYTES,
} from "./trusted-integration-schemas";
export * from "./types";
46 changes: 37 additions & 9 deletions packages/core/src/services/trusted-integration-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import {

const FAILED = "Trusted integration registry rejected";
const MAX_MANIFESTS = 256;
const MAX_MANIFEST_BYTES = 128 * 1024;
/** The largest integration manifest the operations agent reads. */
export const MAX_INTEGRATION_MANIFEST_BYTES = 128 * 1024;
const MAX_TOTAL_BYTES = 4 * 1024 * 1024;
const ID = /^[a-z0-9][a-z0-9-]{0,63}$/;

Expand All @@ -34,6 +35,36 @@ function parseSchema(
return { id: record.id, schema: schema as Record<string, unknown> };
}

function readIntegrationManifest(filesystem: TrustedAuthorityFilesystem, directory: string) {
return filesystem.readManifest(
join(directory, "manifest.json"),
"integration",
MAX_INTEGRATION_MANIFEST_BYTES,
);
}

/**
* Throws unless the operations agent would load `directory` as the installed
* integration `id`: the directory and its `manifest.json` owned by `uid` and
* not group- or other-writable, the manifest a single-link regular file of at
* most 128 KiB, in UTF-8, declaring `id` and a configuration schema the agent
* accepts. The installer runs it on what it is about to install, so an
* installed integration cannot stop the agent from loading the others.
*/
export function assertTrustedIntegrationDirectory(
directory: string,
id: string,
uid: number = process.geteuid?.() ?? 0,
): void {
const filesystem = new TrustedAuthorityFilesystem(directory, uid);
try {
parseSchema(readIntegrationManifest(filesystem, directory), id);
filesystem.recheck();
} finally {
filesystem.close();
}
}

export interface LoadTrustedIntegrationSchemasOptions {
/**
* The built-in integration manifests. Defaults to the release's copies in
Expand All @@ -52,8 +83,9 @@ export interface LoadTrustedIntegrationSchemasOptions {
* Built-in manifests come from the release when bundled, so the checkout's
* `integrations/` and its file permissions do not decide them. Installed
* integrations under `custom_integrations/` are not part of any release:
* they keep the owner and mode checks, anchored at that directory, until
* their installer controls the modes it writes.
* they keep the owner and mode checks, anchored at that directory. The
* installer writes fixed modes and refuses anything these checks would
* reject (`assertTrustedIntegrationDirectory`).
*/
export function loadTrustedIntegrationSchemas(
rootDir: string,
Expand All @@ -76,7 +108,7 @@ export function loadTrustedIntegrationSchemas(
checkoutBuiltInIntegrationManifests(rootDir);
for (const id of builtIns.ids) {
if (!ID.test(id)) throw new Error();
add(id, readRegularFileBytes(builtIns.manifestPath(id), MAX_MANIFEST_BYTES));
add(id, readRegularFileBytes(builtIns.manifestPath(id), MAX_INTEGRATION_MANIFEST_BYTES));
}

const custom = join(rootDir, "custom_integrations");
Expand All @@ -99,11 +131,7 @@ export function loadTrustedIntegrationSchemas(
filesystem.openDirectory(directory);
let bytes: Uint8Array;
try {
bytes = filesystem.readManifest(
join(directory, "manifest.json"),
"integration",
MAX_MANIFEST_BYTES,
);
bytes = readIntegrationManifest(filesystem, directory);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
throw error;
Expand Down
Loading
Loading