diff --git a/packages/dashboard-backend/src/constants/examples.ts b/packages/dashboard-backend/src/constants/examples.ts index b1bec671a..5ce8fe9f8 100644 --- a/packages/dashboard-backend/src/constants/examples.ts +++ b/packages/dashboard-backend/src/constants/examples.ts @@ -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'; }, }; diff --git a/packages/dashboard-backend/src/routes/api/__tests__/dataResolver.spec.ts b/packages/dashboard-backend/src/routes/api/__tests__/dataResolver.spec.ts index 188bf5a71..c4d7a311d 100644 --- a/packages/dashboard-backend/src/routes/api/__tests__/dataResolver.spec.ts +++ b/packages/dashboard-backend/src/routes/api/__tests__/dataResolver.spec.ts @@ -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(); @@ -38,6 +42,7 @@ describe('Data Resolver Route', () => { afterEach(() => { jest.clearAllMocks(); + stubAllowedSourceUrls.splice(0); }); describe('POST ${baseApiPath}/data/resolver', () => { @@ -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); + }); + }); + }); }); diff --git a/packages/dashboard-backend/src/routes/api/dataResolver.ts b/packages/dashboard-backend/src/routes/api/dataResolver.ts index e32d749ca..e7332a659 100644 --- a/packages/dashboard-backend/src/routes/api/dataResolver.ts +++ b/packages/dashboard-backend/src/routes/api/dataResolver.ts @@ -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( @@ -34,6 +99,28 @@ export function registerDataResolverRoute(instance: FastifyInstance) { async function (request: FastifyRequest, reply: FastifyReply): Promise { 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 {