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
29 changes: 29 additions & 0 deletions packages/core/src/helpers/request.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

/* global globalThis */

import { DEFAULT_SITE } from '@dd/core/constants';
import type { RequestOpts } from '@dd/core/types';
import {
Expand Down Expand Up @@ -258,6 +260,33 @@ describe('Request Helpers', () => {
);
});

// Regression test: a customer function running inside the local-execution sandbox can
// reassign globalThis.fetch to an attacker-controlled wrapper before triggering an
// authenticated $.Actions call. Passing network-guard.ts's trustedFetch as fetchImpl must
// make the real, credentialed request reach the actual network layer (asserted via nock)
// without ever invoking the reassigned global.
test('Should use fetchImpl instead of a reassigned globalThis.fetch when provided', async () => {
const { trustedFetch } = await import('@dd/apps-plugin/vite/network-guard');
const { doRequest } = await import('@dd/core/helpers/request');

const originalFetch = globalThis.fetch;
const attackerFetch = jest.fn().mockResolvedValue(new Response('{"stolen":"headers"}'));
(globalThis as { fetch: typeof fetch }).fetch =
attackerFetch as unknown as typeof fetch;

try {
const scope = nock(API_URL).post(API_PATH).reply(200, { data: 'ok' });

const response = await doRequest({ ...requestOpts, fetchImpl: trustedFetch });

expect(scope.isDone()).toBe(true);
expect(response).toEqual({ data: 'ok' });
expect(attackerFetch).not.toHaveBeenCalled();
} finally {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
}
});

test('Should not add bearer authentication headers when the OAuth access token is empty.', async () => {
const fetchMock = jest
.spyOn(global, 'fetch')
Expand Down
13 changes: 11 additions & 2 deletions packages/core/src/helpers/request.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,16 @@ export const NB_RETRIES = 5;

// Do a retriable fetch.
export const doRequest = async <T>(opts: RequestOpts): Promise<T> => {
const { auth, url, method = 'GET', getData, type = 'text', onResponse, signal } = opts;
const {
auth,
url,
method = 'GET',
getData,
type = 'text',
onResponse,
signal,
fetchImpl = fetch,
} = opts;
const retryOpts: retry.Options = {
retries: opts.retries === 0 ? 0 : opts.retries || NB_RETRIES,
onRetry: opts.onRetry,
Expand Down Expand Up @@ -133,7 +142,7 @@ export const doRequest = async <T>(opts: RequestOpts): Promise<T> => {
requestHeaders = { ...requestHeaders, ...headers };
}

response = await fetch(url, { ...requestInit, headers: requestHeaders });
response = await fetchImpl(url, { ...requestInit, headers: requestHeaders });
} catch (error: any) {
// We don't want to retry if there is a non-fetch related error.
bail(error);
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,10 @@ export type RequestOpts = {
minTimeout?: number;
maxTimeout?: number;
signal?: AbortSignal;
// Defaults to a fresh `globalThis.fetch` lookup at call time (so tests can keep mocking the
// global). Callers that must be immune to the global being reassigned at runtime (e.g. the apps
// plugin's authenticated dev-server transport) pass a reference captured before that could happen.
fetchImpl?: typeof fetch;
};

export type ResolvedEntry = { name?: string; resolved: string; original: string };
Expand Down
32 changes: 32 additions & 0 deletions packages/plugins/apps/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@
// This product includes software developed at Datadog (https://www.datadoghq.com/).
// Copyright 2019-Present Datadog, Inc.

/* global globalThis */

import { getAuthenticatedRequest, MissingAuthenticationError } from '@dd/apps-plugin/auth';
import { trustedFetch } from '@dd/apps-plugin/vite/network-guard';
import { doRequest } from '@dd/core/helpers/request';
import { cleanEnv } from '@dd/tests/_jest/helpers/env';

Expand Down Expand Up @@ -39,6 +42,7 @@ describe('Apps Plugin - auth', () => {
apiKey: 'api-key',
appKey: 'app-key',
},
fetchImpl: trustedFetch,
});
});

Expand All @@ -54,6 +58,7 @@ describe('Apps Plugin - auth', () => {
auth: {
accessToken: 'oauth-token',
},
fetchImpl: trustedFetch,
});
});

Expand All @@ -70,10 +75,37 @@ describe('Apps Plugin - auth', () => {
auth: {
accessToken: 'oauth-token',
},
fetchImpl: trustedFetch,
});
});

test('Should throw when no credentials are configured', () => {
expect(() => getAuthenticatedRequest()).toThrow(MissingAuthenticationError);
});

// Regression test: a customer function running inside runAllowed can reassign globalThis.fetch
// to an attacker-controlled wrapper before triggering an authenticated $.Actions call. The
// authenticated request must still use network-guard.ts's trustedFetch (captured before any
// customer code could run), not whatever globalThis.fetch currently resolves to.
test('Should pass the trusted fetch reference through even after globalThis.fetch has been reassigned', async () => {
const originalFetch = globalThis.fetch;
const attackerFetch = jest.fn().mockResolvedValue(new Response('{"stolen":"headers"}'));
(globalThis as { fetch: typeof fetch }).fetch = attackerFetch as unknown as typeof fetch;

try {
process.env.DD_API_KEY = 'api-key';
process.env.DD_APP_KEY = 'app-key';
doRequestMock.mockResolvedValue('ok');

await getAuthenticatedRequest()({ url: 'https://api.datadoghq.com/test' });

expect(doRequestMock).toHaveBeenCalledWith(
expect.objectContaining({ fetchImpl: trustedFetch }),
);
expect(doRequestMock.mock.calls[0][0].fetchImpl).not.toBe(attackerFetch);
expect(attackerFetch).not.toHaveBeenCalled();
} finally {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
}
});
});
27 changes: 23 additions & 4 deletions packages/plugins/apps/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ import { getDDEnvValue } from '@dd/core/helpers/env';
import { doRequest } from '@dd/core/helpers/request';
import type { RequestOpts } from '@dd/core/types';

// Lazy, same reasoning as local-execution.ts's getNetworkGuard(): importing network-guard.ts
// installs its monkeypatches at module-load time, and this module is only ever used by the Vite
// dev server (see getAuthenticatedRequest's callers), so deferring the import keeps that install
// confined to Vite instead of triggering for every bundler that transitively imports this file.
let networkGuardModule: Promise<typeof import('./vite/network-guard')> | undefined;
function getNetworkGuard(): Promise<typeof import('./vite/network-guard')> {
networkGuardModule ??= import('./vite/network-guard').catch((err: unknown) => {
networkGuardModule = undefined;
throw err;
});
return networkGuardModule;
}

export const AUTH_GUIDANCE =
'Set DD_API_KEY and DD_APP_KEY for API-key auth, or set DD_OAUTH_ACCESS_TOKEN ' +
'(or DATADOG_OAUTH_ACCESS_TOKEN) — e.g. by starting the dev server with `datadog-apps dev`.';
Expand All @@ -28,25 +41,31 @@ export const getAuthenticatedRequest = (): DoAuthenticatedRequest => {
const apiKey = getDDEnvValue('API_KEY');
const appKey = getDDEnvValue('APP_KEY');
if (apiKey && appKey) {
return (opts) =>
doRequest({
return async (opts) => {
const { trustedFetch } = await getNetworkGuard();
return doRequest({
...opts,
auth: {
apiKey,
appKey,
},
fetchImpl: trustedFetch,
});
};
}

const accessToken = getDDEnvValue('OAUTH_ACCESS_TOKEN');
if (accessToken) {
return (opts) =>
doRequest({
return async (opts) => {
const { trustedFetch } = await getNetworkGuard();
return doRequest({
...opts,
auth: {
accessToken,
},
fetchImpl: trustedFetch,
});
};
}

throw new MissingAuthenticationError();
Expand Down
10 changes: 10 additions & 0 deletions packages/plugins/apps/src/vite/execution-epoch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export interface EpochScope {
export interface EpochGuard {
/** Starts a new scope, superseding whichever one was previously active. */
start(): EpochScope;
/** True if some started scope hasn't yet been concluded or superseded. */
hasActiveScope(): boolean;
/** Unconditionally invalidates the active scope without starting a new one — the backstop for a scope whose own `fn` never settles. */
forceInvalidate(): void;
}

export function createEpochGuard(): EpochGuard {
Expand All @@ -34,5 +38,11 @@ export function createEpochGuard(): EpochGuard {
},
};
},
hasActiveScope() {
return activeGeneration !== null;
},
forceInvalidate() {
activeGeneration = null;
},
};
}
Loading
Loading