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
9 changes: 1 addition & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,7 @@ cd op-blocknote-hocuspocus
npm install

# Start the server with the appropriate environment variables setup
ALLOWED_DOMAINS=your-openproject-domain.com SECRET=secret12345 npm run start
```

For the server to be able to reach to an OpenProject instance, it is necessary to set the environment variable `ALLOWED_DOMAINS`. It is a comma-separated list of domains (and it allows subdomain matching).

```
ALLOWED_DOMAINS=subdomain-openproject.example.com,top-level-openproject.com`
SECRET=secret12345 npm run start
```

The `SECRET` environment variable is a shared value between this application and OpenProject. Make sure to configure the same value in OpenProject - Settings Hocuspocus secret and in the `SECRET` environment variable of this project.
Expand All @@ -36,7 +30,6 @@ docker pull openproject/hocuspocus:latest

docker run -d \
-p 1234:1234 \
-e ALLOWED_DOMAINS=your-openproject-domain.com \
-e SECRET=secret12345 \
openproject/hocuspocus:latest
```
Expand Down
22 changes: 0 additions & 22 deletions src/extensions/openProjectApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,27 +30,6 @@ export class OpenProjectApi implements Extension {
}
const decryptedToken = decryptToken(token);

const allowedDomains = process.env.ALLOWED_DOMAINS?.split(',') || [];
if (allowedDomains.length <= 0) {
throw new Error('Unauthorized: No allowed domains configured.');
}

try {
const url = new URL(resourceUrl);
const isAllowed = allowedDomains.some(domain =>
url.hostname === domain.trim() || url.hostname.endsWith('.' + domain.trim())
);

if (!isAllowed) {
throw new Error('Unauthorized: Invalid base URL domain.');
}
} catch (error) {
if (error instanceof TypeError) {
throw new Error('Unauthorized: Invalid base URL format.');
}
throw error;
}

const response = await fetch(resourceUrl, {
method: "GET",
headers: {
Expand All @@ -64,7 +43,6 @@ export class OpenProjectApi implements Extension {
}
const jsonData = await response.json() as ApiResponseDocument;

// data.documentName = resourceUrl;
data.context.resourceUrl = resourceUrl;
data.context.token = decryptedToken;
if (!jsonData._links?.update) {
Expand Down
48 changes: 4 additions & 44 deletions test/extensions/openProjectApi.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,14 @@ import { OpenProjectApi, createEditor } from "../../src/extensions/openProjectAp

describe("OpenProjectApi", () => {
let fetchMock: any;
let originalAllowedDomains: string | undefined;

beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
originalAllowedDomains = process.env.ALLOWED_DOMAINS;
process.env.ALLOWED_DOMAINS = 'test.api,example.com';
});

afterEach(() => {
vi.unstubAllGlobals();
process.env.ALLOWED_DOMAINS = originalAllowedDomains;
});

describe("onAuthenticate", () => {
Expand All @@ -28,7 +24,7 @@ describe("OpenProjectApi", () => {
).rejects.toThrowError("Unauthorized: Token missing.");
});

test("when the token is invalid", async () => {
test("when the token is invalid throw an error", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
// Invalid token, generated with a different secret
Expand All @@ -37,51 +33,15 @@ describe("OpenProjectApi", () => {
).rejects.toThrowError("Unsupported state or unable to authenticate data");
});

test("when ALLOWED_DOMAINS is not configured throw an error", async () => {
delete process.env.ALLOWED_DOMAINS;

await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: No allowed domains configured.");
});

test("when the resourceUrl has invalid format throw an error", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "not a valid url",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Invalid base URL format.");
});
fetchMock.mockResolvedValueOnce({ throws: new TypeError("is not a valid URL") });

test("when the resourceUrl domain is not in ALLOWED_DOMAINS throw an error", async () => {
await expect(() =>
new OpenProjectApi().onAuthenticate({
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "https://malicious.com/something/1",
documentName: "not a valid url",
} as unknown as onAuthenticatePayload)
).rejects.toThrowError("Unauthorized: Invalid base URL domain.");
});

test("when the resourceUrl subdomain matches ALLOWED_DOMAINS it should be accepted", async () => {
fetchMock.mockResolvedValueOnce({
ok: true,
status: 200,
json: () => Promise.resolve({}),
});

const data = {
context: {},
connectionConfig: {},
token: "7u+b+QRJN7qANls=--URNw83hIWBq3MMIA--jtl+UPdtbniQVFNOs2EcAw==",
documentName: "https://subdomain.test.api/api/v3/documents/1",
} as unknown as onAuthenticatePayload;

await new OpenProjectApi().onAuthenticate(data);

expect(data.context.resourceUrl).toEqual("https://subdomain.test.api/api/v3/documents/1");
).rejects.toThrowError("Unauthorized: Invalid token or document access denied.");
});

test("when the server does not authorize the request throw an error", async () => {
Expand Down