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
2 changes: 2 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@ const config: Config = {
moduleNameMapper: {
"^@cli/(.*)$": "<rootDir>/src/cli/$1",
"^@entry/(.*)$": "<rootDir>/src/entry/$1",
"^@frame/(.*)$": "<rootDir>/src/frame/$1",
"^@locale/(.*)$": "<rootDir>/src/locale/$1",
"^@offscreen/(.*)$": "<rootDir>/src/offscreen/$1",
"^@message/(.*)$": "<rootDir>/src/message/$1",
"^@sandbox/(.*)$": "<rootDir>/src/sandbox/$1",
"^@service/(.*)$": "<rootDir>/src/service/$1",
"^@storage/(.*)$": "<rootDir>/src/storage/$1",
"^@transport/(.*)$": "<rootDir>/src/transport/$1",
Expand Down
36 changes: 32 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,10 @@
"types": "./dist/relay/index.d.ts",
"default": "./dist/relay/index.js"
},
"./sandbox": {
"types": "./dist/sandbox/index.d.ts",
"default": "./dist/sandbox/index.js"
},
"./service": {
"types": "./dist/service/index.d.ts",
"default": "./dist/service/index.js"
Expand Down Expand Up @@ -142,6 +146,8 @@
"mini-css-extract-plugin": "^2.9.2",
"nanoid": "^5.1.4",
"pluralize": "^8.0.0",
"postcss": "^8.5.15",
"postcss-scss": "^4.0.9",
"rspack-plugin-virtual-module": "^1.0.1",
"sass": "^1.83.4",
"sass-loader": "^16.0.4",
Expand Down
38 changes: 38 additions & 0 deletions src/cli/builders/csp/AbstractCsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type {CspBuilder} from "./types";

export default abstract class AbstractCsp<TPolicy> implements CspBuilder<TPolicy> {
protected readonly sources: Map<string, Set<string>> = new Map();

public abstract add(policy: TPolicy): this;

public abstract build(): string | undefined;

protected addSources<TSources extends object>(
sources: TSources | undefined,
directives: Partial<Record<keyof TSources & string, string>>
): void {
for (const [key, values] of Object.entries(sources || {}) as Array<[keyof TSources & string, string[]]>) {
const directive = directives[key];

if (!directive || !values) {
continue;
}

const source = this.sources.get(directive) ?? new Set();

for (const value of values) {
source.add(value);
}

this.sources.set(directive, source);
}
}

protected sourceDirectives(): string[][] {
return Array.from(this.sources.entries()).map(([directive, values]) => [directive, ...values]);
}

protected serialize(directives: string[][]): string {
return directives.map(parts => `${parts.join(" ")};`).join(" ");
}
}
35 changes: 35 additions & 0 deletions src/cli/builders/csp/Csp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import Csp from "./Csp";

import {CspSource} from "@typing/csp";

describe("Csp", () => {
test("returns no policy until at least one view CSP is added", () => {
expect(new Csp().build()).toBeUndefined();
});

test("merges view CSP sources into extension page directives", () => {
const policy = new Csp()
.add({
wasm: true,
sources: {
connect: [CspSource.Self, "https://api.example.com"],
image: [CspSource.Self, CspSource.Data],
style: [CspSource.Self, CspSource.UnsafeInline],
},
})
.add({
sources: {
image: [CspSource.Blob],
worker: [CspSource.Blob],
},
})
.build();

expect(policy).toContain("script-src 'self' 'wasm-unsafe-eval';");
expect(policy).toContain("object-src 'self';");
expect(policy).toContain("connect-src 'self' https://api.example.com;");
expect(policy).toContain("img-src 'self' data: blob:;");
expect(policy).toContain("style-src 'self' 'unsafe-inline';");
expect(policy).toContain("worker-src blob:;");
});
});
48 changes: 48 additions & 0 deletions src/cli/builders/csp/Csp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import {CspSource} from "@typing/csp";
import type {CspConfig, CspSources} from "@typing/csp";

import AbstractCsp from "./AbstractCsp";

const SourceDirectives: Record<keyof CspSources, string> = {
connect: "connect-src",
image: "img-src",
style: "style-src",
font: "font-src",
media: "media-src",
worker: "worker-src",
child: "child-src",
frame: "frame-src",
};

export default class Csp extends AbstractCsp<CspConfig> {
private active = false;
private wasm = false;

public add(csp: CspConfig): this {
this.active = true;

if (csp.wasm) {
this.wasm = true;
}

this.addSources(csp.sources, SourceDirectives);

return this;
}

public build(): string | undefined {
if (!this.active) {
return;
}

const script = ["script-src", CspSource.Self];

if (this.wasm) {
script.push("'wasm-unsafe-eval'");
}

const directives = [script, ["object-src", CspSource.Self], ...this.sourceDirectives()];

return this.serialize(directives);
}
}
52 changes: 52 additions & 0 deletions src/cli/builders/csp/SandboxCsp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import SandboxCsp from "./SandboxCsp";

import {SandboxAllow, SandboxSource} from "@typing/sandbox";

describe("SandboxCsp", () => {
test("builds the default sandbox policy", () => {
expect(new SandboxCsp().add({}).build()).toBe(
"sandbox allow-scripts; script-src 'self' 'unsafe-eval'; child-src 'self';"
);
});

test("keeps the default child source when custom sources are added", () => {
const policy = new SandboxCsp()
.add({
sources: {
image: [SandboxSource.Self],
},
})
.build();

expect(policy).toContain("child-src 'self';");
expect(policy).toContain("img-src 'self';");
});

test("merges allow tokens and sources", () => {
const policy = new SandboxCsp()
.add({
eval: false,
inline: true,
allow: [SandboxAllow.Forms, "modals"],
sources: {
image: [SandboxSource.Self, SandboxSource.Data],
style: [SandboxSource.Self, SandboxSource.UnsafeInline],
},
})
.add({
eval: true,
allow: [SandboxAllow.Popups],
sources: {
image: [SandboxSource.Blob],
child: [SandboxSource.Blob],
},
})
.build();

expect(policy).toContain("sandbox allow-scripts allow-forms allow-modals allow-popups;");
expect(policy).toContain("script-src 'self' 'unsafe-eval' 'unsafe-inline';");
expect(policy).toContain("img-src 'self' data: blob:;");
expect(policy).toContain("style-src 'self' 'unsafe-inline';");
expect(policy).toContain("child-src 'self' blob:;");
});
});
77 changes: 77 additions & 0 deletions src/cli/builders/csp/SandboxCsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import {SandboxAllow, SandboxCspConfig, SandboxSource} from "@typing/sandbox";

import AbstractCsp from "./AbstractCsp";

const SourceDirectives: Record<keyof NonNullable<SandboxCspConfig["sources"]>, string> = {
connect: "connect-src",
image: "img-src",
style: "style-src",
font: "font-src",
media: "media-src",
worker: "worker-src",
child: "child-src",
};

export default class SandboxCsp extends AbstractCsp<SandboxCspConfig> {
private active = false;
private eval = false;
private inline = false;
private readonly allow = new Set<SandboxAllow | `${SandboxAllow}`>();

public add(csp: SandboxCspConfig): this {
this.active = true;

csp = {
eval: true,
inline: false,
allow: [],
...csp,
};

if (csp.eval) {
this.eval = true;
}

if (csp.inline) {
this.inline = true;
}

for (const value of csp.allow || []) {
this.allow.add(value);
}

this.addSources(csp.sources, SourceDirectives);

return this;
}

public build(): string | undefined {
if (!this.active) {
return;
}

const sandbox = ["sandbox", "allow-scripts", ...Array.from(this.allow).map(value => `allow-${value}`)];
const script = ["script-src", SandboxSource.Self];

if (this.eval) {
script.push("'unsafe-eval'" as SandboxSource);
}

if (this.inline) {
script.push(SandboxSource.UnsafeInline);
}

const directives = [sandbox, script, ...this.sourceDirectives()];

return this.serialize(directives);
}

protected sourceDirectives(): string[][] {
const sources = new Map(this.sources);
const child = sources.get("child-src") || new Set();

sources.set("child-src", new Set([SandboxSource.Self, ...child]));

return Array.from(sources.entries()).map(([directive, values]) => [directive, ...values]);
}
}
3 changes: 3 additions & 0 deletions src/cli/builders/csp/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export {default as Csp} from "./Csp";
export {default as SandboxCsp} from "./SandboxCsp";
export type {CspBuilder} from "./types";
5 changes: 5 additions & 0 deletions src/cli/builders/csp/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export interface CspBuilder<TCsp> {
add(csp: TCsp): this;

build(): string | undefined;
}
Loading
Loading