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: 1 addition & 1 deletion packages/dashboard-backend/src/constants/examples.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export const dockerConfigExample = {

export const dataResolverSchemaExample = {
get url() {
return 'http://127.0.0.1:8080/dashboard/devfile-registry/devfiles/index.json';
return 'https://raw.githubusercontent.com/devfile-samples/devfile-sample-python-basic/main/devfile.yaml';
},
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ import { setup, teardown } from '@/utils/appBuilder';
jest.mock('@/routes/api/helpers/getCertificateAuthority');
jest.mock('../helpers/getDevWorkspaceClient.ts');
jest.mock('../helpers/getServiceAccountToken.ts');

const { stubAllowedSourceUrls } = jest.requireMock(
'../helpers/getDevWorkspaceClient.ts',
) as typeof import('@/routes/api/helpers/__mocks__/getDevWorkspaceClient');
const axiosInstanceMock = jest.fn();
(axiosInstance.get as jest.Mock).mockImplementation(axiosInstanceMock);
const defaultAxiosInstanceMock = jest.fn();
Expand All @@ -38,6 +42,7 @@ describe('Data Resolver Route', () => {

afterEach(() => {
jest.clearAllMocks();
stubAllowedSourceUrls.splice(0);
});

describe('POST ${baseApiPath}/data/resolver', () => {
Expand Down Expand Up @@ -143,4 +148,72 @@ describe('Data Resolver Route', () => {
});
});
});

describe('SSRF protection', () => {
describe('blocks requests to private addresses', () => {
test.each([
'http://127.0.0.1/secret',
'http://localhost/secret',
'http://169.254.169.254/latest/meta-data/',
'http://10.0.0.1/internal',
'http://172.16.0.1/internal',
'http://192.168.1.1/internal',
// IPv4-mapped IPv6 — bypass attempt
'http://[::ffff:169.254.169.254]/',
'http://[::ffff:127.0.0.1]/',
'http://[::ffff:10.0.0.1]/',
])('blocks %s', async url => {
const res = await app.inject().post(`${baseApiPath}/data/resolver`).payload({ url });
expect(res.statusCode).toEqual(403);
expect(defaultAxiosInstanceMock).not.toHaveBeenCalled();
});
});

describe('invalid URL', () => {
test('returns 400 for malformed URL', async () => {
const res = await app
.inject()
.post(`${baseApiPath}/data/resolver`)
.payload({ url: 'http://' });
expect(res.statusCode).toEqual(400);
expect(defaultAxiosInstanceMock).not.toHaveBeenCalled();
});
});

describe('allowlist enforcement', () => {
beforeEach(() => {
stubAllowedSourceUrls.push('https://allowed.example.com/*');
});

test('blocks URL not in allowlist', async () => {
const res = await app
.inject()
.post(`${baseApiPath}/data/resolver`)
.payload({ url: 'https://blocked.example.com/devfile.yaml' });
expect(res.statusCode).toEqual(403);
expect(defaultAxiosInstanceMock).not.toHaveBeenCalled();
});

test('allows URL matching allowlist wildcard', async () => {
defaultAxiosInstanceMock.mockResolvedValueOnce({ status: 200, data: 'devfile content' });
const res = await app
.inject()
.post(`${baseApiPath}/data/resolver`)
.payload({ url: 'https://allowed.example.com/devfile.yaml' });
expect(res.statusCode).toEqual(200);
expect(res.body).toEqual('devfile content');
});
});

describe('empty allowlist', () => {
test('allows any public URL when allowlist is not configured', async () => {
defaultAxiosInstanceMock.mockResolvedValueOnce({ status: 200, data: 'devfile content' });
const res = await app
.inject()
.post(`${baseApiPath}/data/resolver`)
.payload({ url: 'https://github.com/devfile.yaml' });
expect(res.statusCode).toEqual(200);
});
});
});
});
87 changes: 87 additions & 0 deletions packages/dashboard-backend/src/routes/api/dataResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,79 @@ import { baseApiPath } from '@/constants/config';
import { dataResolverSchema } from '@/constants/schemas';
import { restParams } from '@/models';
import { axiosInstance, axiosInstanceNoCert } from '@/routes/api/helpers/getCertificateAuthority';
import { getDevWorkspaceClient } from '@/routes/api/helpers/getDevWorkspaceClient';
import { getServiceAccountToken } from '@/routes/api/helpers/getServiceAccountToken';
import { getSchema } from '@/services/helpers';

const tags = ['Data Resolver'];

const config = {
headers: { 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Methods': 'GET' },
maxRedirects: 0,
};

function isPrivateOctets(a: number, b: number): boolean {
return (
a === 127 ||
a === 10 ||
(a === 172 && b >= 16 && b <= 31) ||
(a === 192 && b === 168) ||
(a === 169 && b === 254)
);
}

function isPrivateHostname(hostname: string): boolean {
if (hostname === 'localhost' || hostname === '::1' || hostname === '[::1]') {
return true;
}

// IPv4-mapped IPv6: [::ffff:XXXX:YYYY] where each group is a 16-bit hex word.
// e.g. [::ffff:a9fe:a9fe] maps to 169.254.169.254.
const ipv4Mapped = hostname.match(/^\[::ffff:([0-9a-f]+):([0-9a-f]+)\]$/i);
if (ipv4Mapped) {
const w1 = parseInt(ipv4Mapped[1], 16);
return isPrivateOctets((w1 >> 8) & 0xff, w1 & 0xff);
}

const parts = hostname.split('.');
if (parts.length === 4) {
const octets = parts.map(Number);
if (octets.every(o => Number.isInteger(o) && o >= 0 && o <= 255)) {
return isPrivateOctets(octets[0], octets[1]);
}
}
return false;
}

function isUrlAllowed(url: string, allowedSourceUrls: string[]): boolean {
if (allowedSourceUrls.length === 0) {
return true;
}
for (const allowedUrl of allowedSourceUrls) {
if (allowedUrl.includes('*')) {
let pattern = allowedUrl.trim();
if (!pattern.startsWith('*')) {
pattern = `^${pattern}`;
}
if (!pattern.endsWith('*')) {
pattern = `${pattern}$`;
}
// Intentionally mirrors the frontend isSourceAllowed() pattern logic:
// non-wildcard URL chars (including '.') are not regex-escaped.
// Allowlist entries come from the operator (trusted input).
pattern = pattern.replace(/\*/g, '.*');
if (new RegExp(pattern).test(url)) {
return true;
}
} else {
if (allowedUrl.trim() === url) {
return true;
}
}
}
return false;
}

export function registerDataResolverRoute(instance: FastifyInstance) {
instance.register(async server => {
server.post(
Expand All @@ -34,6 +99,28 @@ export function registerDataResolverRoute(instance: FastifyInstance) {
async function (request: FastifyRequest, reply: FastifyReply): Promise<string | void> {
const { url } = request.body as restParams.IYamlResolverParams;

let parsedUrl: URL;
try {
parsedUrl = new URL(url);
} catch {
reply.code(400).send('Invalid URL');
return;
}

if (isPrivateHostname(parsedUrl.hostname)) {
reply.code(403).send('Requests to private addresses are not allowed');
return;
}

const token = getServiceAccountToken();
const { serverConfigApi } = getDevWorkspaceClient(token);
const cheCustomResource = await serverConfigApi.fetchCheCustomResource();
const allowedSourceUrls = serverConfigApi.getAllowedSourceUrls(cheCustomResource);
if (!isUrlAllowed(url, allowedSourceUrls)) {
reply.code(403).send('URL is not in the allowed sources list');
return;
}

try {
let response: AxiosResponse;
try {
Expand Down
Loading