From 61d4f8ce634c4e47fc799924a2027b76e6552d07 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Wed, 4 Dec 2024 13:11:49 +0100 Subject: [PATCH 01/32] First draft of auth via remotes endpoint --- src/app/api/proxy/route.ts | 70 +---------- .../checkIfJsonOrYaml.ts | 12 ++ .../downloadFile.ts | 58 +++++++++ .../[encodedRemoteSpecification]/route.ts | 110 ++++++++++++++++++ .../projects/data/GitHubProjectDataSource.ts | 2 +- 5 files changed, 183 insertions(+), 69 deletions(-) create mode 100644 src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts create mode 100644 src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts create mode 100644 src/app/api/remotes/[encodedRemoteSpecification]/route.ts diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index 256ee955..ee606f0c 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server" import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" import { session } from "@/composition" -import { parse as parseYaml } from "yaml" +import { downloadFile } from "../remotes/[encodedRemoteSpecification]/downloadFile" +import { checkIfJsonOrYaml } from "../remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml" const ErrorName = { MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", @@ -46,70 +47,3 @@ export async function GET(req: NextRequest) { } } } - -async function downloadFile(params: { - url: URL, - maxBytes: number, - timeoutInSeconds: number -}): Promise { - const { url, maxBytes, timeoutInSeconds } = params - const abortController = new AbortController() - const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000) - const headers: {[key: string]: string} = {} - // Extract basic auth from URL and construct an Authorization header instead. - if ((url.username && url.username.length > 0) || (url.password && url.password.length > 0)) { - const username = decodeURIComponent(url.username) - const password = decodeURIComponent(url.password) - headers["Authorization"] = "Basic " + btoa(`${username}:${password}`) - } - // Make sure basic auth is removed from URL. - const urlWithoutAuth = url - urlWithoutAuth.username = "" - urlWithoutAuth.password = "" - const response = await fetch(urlWithoutAuth, { - method: "GET", - headers, - signal: AbortSignal.any([abortController.signal, timeoutSignal]) - }) - if (!response.body) { - throw new Error("Response body unavailable") - } - let totalBytes = 0 - let didExceedMaxBytes = false - const reader = response.body.getReader() - const chunks: Uint8Array[] = [] - // eslint-disable-next-line no-constant-condition - while (true) { - // eslint-disable-next-line no-await-in-loop - const { done, value } = await reader.read() - if (done) { - break - } - totalBytes += value.length - chunks.push(value) - if (totalBytes >= maxBytes) { - didExceedMaxBytes = true - abortController.abort() - break - } - } - if (didExceedMaxBytes) { - const error = new Error("Maximum file size exceeded") - error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED - throw error - } - const blob = new Blob(chunks) - const arrayBuffer = await blob.arrayBuffer() - const decoder = new TextDecoder() - return decoder.decode(arrayBuffer) -} - -function checkIfJsonOrYaml(fileText: string) { - try { - parseYaml(fileText) // will also parse JSON as it is a subset of YAML - } catch { - const error = new Error("File is not JSON or YAML") - error.name = ErrorName.NOT_JSON_OR_YAML - throw error - } -} diff --git a/src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts b/src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts new file mode 100644 index 00000000..6668814f --- /dev/null +++ b/src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts @@ -0,0 +1,12 @@ +import { parse as parseYaml } from "yaml" +import { ErrorName } from "./route" + +export function checkIfJsonOrYaml(fileText: string) { + try { + parseYaml(fileText) // will also parse JSON as it is a subset of YAML + } catch { + const error = new Error("File is not JSON or YAML") + error.name = ErrorName.NOT_JSON_OR_YAML + throw error + } +} diff --git a/src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts b/src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts new file mode 100644 index 00000000..cb95a6f7 --- /dev/null +++ b/src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts @@ -0,0 +1,58 @@ +import { ErrorName } from "./route"; + +export async function downloadFile(params: { + url: URL; + maxBytes: number; + timeoutInSeconds: number; + basicAuthUsername?: string; + basicAuthPassword?: string; +}): Promise { + const { url, maxBytes, timeoutInSeconds, basicAuthUsername, basicAuthPassword } = params; + const abortController = new AbortController(); + const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000); + const headers: { [key: string]: string; } = {}; + // Extract basic auth from URL and construct an Authorization header instead. + if (basicAuthUsername && basicAuthPassword) { + headers["Authorization"] = "Basic " + btoa(`${basicAuthUsername}:${basicAuthPassword}`); + } + // Make sure basic auth is removed from URL. + const urlWithoutAuth = url; + urlWithoutAuth.username = ""; + urlWithoutAuth.password = ""; + const response = await fetch(urlWithoutAuth, { + method: "GET", + headers, + signal: AbortSignal.any([abortController.signal, timeoutSignal]) + }); + if (!response.body) { + throw new Error("Response body unavailable"); + } + let totalBytes = 0; + let didExceedMaxBytes = false; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.length; + chunks.push(value); + if (totalBytes >= maxBytes) { + didExceedMaxBytes = true; + abortController.abort(); + break; + } + } + if (didExceedMaxBytes) { + const error = new Error("Maximum file size exceeded"); + error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED; + throw error; + } + const blob = new Blob(chunks); + const arrayBuffer = await blob.arrayBuffer(); + const decoder = new TextDecoder(); + return decoder.decode(arrayBuffer); +} diff --git a/src/app/api/remotes/[encodedRemoteSpecification]/route.ts b/src/app/api/remotes/[encodedRemoteSpecification]/route.ts new file mode 100644 index 00000000..94362cf2 --- /dev/null +++ b/src/app/api/remotes/[encodedRemoteSpecification]/route.ts @@ -0,0 +1,110 @@ +import { NextRequest, NextResponse } from "next/server" +import { session } from "@/composition" +import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" +import { privateDecrypt, constants } from 'crypto' +import { z } from 'zod'; +import { downloadFile } from "./downloadFile"; +import { checkIfJsonOrYaml } from "./checkIfJsonOrYaml"; + +export const ErrorName = { + MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", + TIMEOUT: "TimeoutError", + NOT_JSON_OR_YAML: "NotJsonOrYamlError", +} + +interface RemoteSpecificationParams { + encodedRemoteSpecification: string +} + +const AuthSchema = z.object({ + type: z.string(), + username: z.string(), + password: z.string(), +}); +type Auth = z.infer; + +const RemoteSpecificationSchema = z.object({ + url: z.string().url(), + auth: AuthSchema.optional(), +}); +type RemoteSpecification = z.infer; + +const privateKey = `-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPvqSIXsEJIHro +Mw3Lsxmq91LVVBCEnIYZO4rVBk2mdA7FADwqfv/2T0tA49eYd8RcZp8CHWDmK36a +OVsN3sMy3Lh7YwaIFqOAyD6gm1hPlpb0oJE4U3a9aZXh9WnaWepCKNHJhxSWhlL2 +6h+miv8vblSFvG+6v0FVbh4obXDMbp/ruf+BHZEns+KMczkQp5HaNHLRVoeW76n8 +E473cM4kdSeJBSJmcSdhMB+b0P1QypozbJxqh+tztMPdqf9w58QKFE4KTqyVE/pw +RnLCBN31YaD6CL3VtGGHJdZwKqLfEWfnRvWBu87HOJHwVXCTAW8osbLXI5SdZAfJ +bTEcSaXTAgMBAAECggEAAaf9zQZXwg8NJAn68pm0FkJc0geqFmlqQjaxy2ISBvSq +o+bS8RAnl7UdZphuTJ7hhAEhj+H3Wa3ufCfLc1DMHu2Qw+yELtB6Do8lSG6CMkaK +8z95jcLrnWwuAx5AH6tmgodtglCHA9r8t+pf+zEyzBvDzEHB+FaBhVJ5i3CaOgnn +sldRDFXPbIK5vp9znNmJiCttdFh4o1zClVybH+GdDXERlal9zBAdOGR9RQsW5Ps8 +rEldFdInFdW2Jwzg1Q2AMu0+uxdpGDX2s//jp6q99W4VZFrq1NAoqfGDAaRePJ23 +w5K1qoLTXF+IkvBRK749SznUcZyE4ZmLHJoKCVjMYQKBgQDxulf0fw1h1+jQ9OmC +Dim8UV82WyjOknDyhpdpHauhcPQQwMDgF6ugZA5H5HZRAnjWNctEcQ72V+ET/6eU +9SUmvnI6z+gd/eJGGKQxkNoTaWkkRqc6RSBwiVUc1Fv3x0nqhtd65VGIi6i6psUn +gnLvMI1QxANtcgXdb3qp1pBAGQKBgQDcAqbJ/5BgiYGgef/QoyRTI6M8ZQWJ4lf9 +xrYzY+96T8JNEBshFu3gqCOclcV8jS21BTbWudpyhDradmdRRsypYTJh0HBxfzd2 +gXsMZ27jDMQ8q91xhgaziEGTVKrx/02dHJaLUza6MqkfVVyAzWDx0Hu6sN5Qxe4D +8bBSmO+iywKBgC0t7+yBpqWn7hrH+7DUJtbMuqf1J85cLoIVx8zcv8xfyS4saKA5 +rFlA+i5TtA12EdGvojs7illemXHccZz0qKnyJHV7kF2yqw0A5AdjlG7WX9Fo5y6L +5wFBmcfWpQ3NkLIl27Zbj/6eY73nF6hHyGWORItY528YRaJaiKmfsbxZAoGBAJUW +6uW53KG+rOwNoHBHDaeVX9neb2lny876aJ/cmf0drYLBZlD/E8YIytEioUhs90tT +ND1AhqrRtnwyfoMSYkBp0FV+haQz3GbfCX53XSpZjWW75X03oLTqod1wI8OICZVt +OQtDIbP9/qNwGhZils5nRGFX19+OsWNU1fKzFrkPAoGARA4wcLbshkxX6KGhfE5x +LB1apVkCG0y6jUAq4469svfpeILUtqgNBMBWYa9O7ID5NSfRn5DjyEl9U3SYz0H4 +psXP4eE1x86Lz37lAuXPpnrtE93xiRVFMYB3KXdyeGlTpwKLSGbf8pex85yrQU42 +CKpLoFTucpZ0hyD+upn2KMI= +-----END PRIVATE KEY-----` + +export async function GET(req: NextRequest, { params }: { params: RemoteSpecificationParams }) { + const isAuthenticated = await session.getIsAuthenticated() + if (!isAuthenticated) { + return makeUnauthenticatedAPIErrorResponse() + } + + const encryptedRemoteSpecification = decodeURIComponent(params.encodedRemoteSpecification) + + // Decrypt the encrypted specification + const decryptedRemoteSpecification = privateDecrypt( + { + key: privateKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + Buffer.from(encryptedRemoteSpecification, 'base64') + ).toString('utf-8') + + const remoteSpecification: RemoteSpecification = RemoteSpecificationSchema.parse(JSON.parse(decryptedRemoteSpecification)) + + console.log(remoteSpecification) + + let url: URL + try { + url = new URL(remoteSpecification.url) + } catch { + return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") + } + try { + const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES")) + const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS")) + const maxBytes = maxMegabytes * 1024 * 1024 + const fileText = await downloadFile({ url, maxBytes, timeoutInSeconds, basicAuthUsername: remoteSpecification.auth?.username, basicAuthPassword: remoteSpecification.auth?.password }) + checkIfJsonOrYaml(fileText) + return new NextResponse(fileText, { status: 200, headers: { "Content-Type": "text/plain" } }) + } catch (error) { + if (error instanceof Error == false) { + return makeAPIErrorResponse(500, "An unknown error occurred.") + } + if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) { + return makeAPIErrorResponse(413, "The operation was aborted.") + } else if (error.name === ErrorName.TIMEOUT) { + return makeAPIErrorResponse(408, "The operation timed out.") + } else if (error.name === ErrorName.NOT_JSON_OR_YAML) { + return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.") + } else { + return makeAPIErrorResponse(500, error.message) + } + } +} diff --git a/src/features/projects/data/GitHubProjectDataSource.ts b/src/features/projects/data/GitHubProjectDataSource.ts index bc79fe25..bd186693 100644 --- a/src/features/projects/data/GitHubProjectDataSource.ts +++ b/src/features/projects/data/GitHubProjectDataSource.ts @@ -170,7 +170,7 @@ export default class GitHubProjectDataSource implements IProjectDataSource { return { id: this.makeURLSafeID((e.id || e.name).toLowerCase()), name: e.name, - url: `/api/proxy?url=${encodeURIComponent(e.url)}` + url: "/api/remotes/EKNYViMOSUnJggD4c4UwEoOGkGKZIPjWtijfeoYqgrkRP%2FIwXa770oxwRsVTdVFzmbjWuartdrUhUjkq7EyT4m3NBQOph0UaRTQhFgxm4Q5v2KJ%2BkhJ6TTKwiEgEdS%2BdOvTzAzXtk80T4amaNdeET9JVGJo0y8G47qtUIZCWmyzxamTnOJYOhkj4NcH9XlyafghwUV%2FO%2FAShlzwscFPy%2BlDFuhl8jmYV4fvClI2%2F4iFyew%2Bg5LGvNXPTS8wEZcz9GBKrcgSE6ScK3oeAGesKPyYblkKiU7dAxi%2FlRs9jiKQId%2BEFLWFcDgNw8aLDyZjKMT2u4gF9dfLx4iRQhaJ7KQ%3D%3D" } }) versions.push({ From ef793d5b70dcb31fcf64c4ba7d909ccca5e192d0 Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Wed, 4 Dec 2024 13:32:01 +0100 Subject: [PATCH 02/32] Introduce encryption service --- .env.example | 2 + src/app/api/proxy/route.ts | 9 +- .../checkIfJsonOrYaml.ts | 12 -- .../downloadFile.ts | 58 --------- .../[encodedRemoteSpecification]/route.ts | 110 ------------------ .../remotes/[encryptedRemoteConfig]/route.ts | 72 ++++++++++++ src/common/encryption/EncryptionService.ts | 42 +++++++ src/common/utils/fileUtils.ts | 74 ++++++++++++ src/composition.ts | 8 +- 9 files changed, 198 insertions(+), 189 deletions(-) delete mode 100644 src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts delete mode 100644 src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts delete mode 100644 src/app/api/remotes/[encodedRemoteSpecification]/route.ts create mode 100644 src/app/api/remotes/[encryptedRemoteConfig]/route.ts create mode 100644 src/common/encryption/EncryptionService.ts create mode 100644 src/common/utils/fileUtils.ts diff --git a/.env.example b/.env.example index 1e98a941..239f191a 100644 --- a/.env.example +++ b/.env.example @@ -22,3 +22,5 @@ GITHUB_CLIENT_ID=GitHub App client ID GITHUB_CLIENT_SECRET=GitHub App client secret GITHUB_APP_ID=123456 GITHUB_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key - see README.md for more info +ENCRYPTION_PUBLIC_KEY_BASE_64=base 64 encoded version of the public key +ENCRYPTION_PRIVATE_KEY_BASE_64=base 64 encoded version of the private key diff --git a/src/app/api/proxy/route.ts b/src/app/api/proxy/route.ts index ee606f0c..3a1e2716 100644 --- a/src/app/api/proxy/route.ts +++ b/src/app/api/proxy/route.ts @@ -1,14 +1,7 @@ import { NextRequest, NextResponse } from "next/server" import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" import { session } from "@/composition" -import { downloadFile } from "../remotes/[encodedRemoteSpecification]/downloadFile" -import { checkIfJsonOrYaml } from "../remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml" - -const ErrorName = { - MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", - TIMEOUT: "TimeoutError", - NOT_JSON_OR_YAML: "NotJsonOrYamlError", -} +import { checkIfJsonOrYaml, downloadFile, ErrorName } from "@/common/utils/fileUtils" export async function GET(req: NextRequest) { const isAuthenticated = await session.getIsAuthenticated() diff --git a/src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts b/src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts deleted file mode 100644 index 6668814f..00000000 --- a/src/app/api/remotes/[encodedRemoteSpecification]/checkIfJsonOrYaml.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { parse as parseYaml } from "yaml" -import { ErrorName } from "./route" - -export function checkIfJsonOrYaml(fileText: string) { - try { - parseYaml(fileText) // will also parse JSON as it is a subset of YAML - } catch { - const error = new Error("File is not JSON or YAML") - error.name = ErrorName.NOT_JSON_OR_YAML - throw error - } -} diff --git a/src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts b/src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts deleted file mode 100644 index cb95a6f7..00000000 --- a/src/app/api/remotes/[encodedRemoteSpecification]/downloadFile.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { ErrorName } from "./route"; - -export async function downloadFile(params: { - url: URL; - maxBytes: number; - timeoutInSeconds: number; - basicAuthUsername?: string; - basicAuthPassword?: string; -}): Promise { - const { url, maxBytes, timeoutInSeconds, basicAuthUsername, basicAuthPassword } = params; - const abortController = new AbortController(); - const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000); - const headers: { [key: string]: string; } = {}; - // Extract basic auth from URL and construct an Authorization header instead. - if (basicAuthUsername && basicAuthPassword) { - headers["Authorization"] = "Basic " + btoa(`${basicAuthUsername}:${basicAuthPassword}`); - } - // Make sure basic auth is removed from URL. - const urlWithoutAuth = url; - urlWithoutAuth.username = ""; - urlWithoutAuth.password = ""; - const response = await fetch(urlWithoutAuth, { - method: "GET", - headers, - signal: AbortSignal.any([abortController.signal, timeoutSignal]) - }); - if (!response.body) { - throw new Error("Response body unavailable"); - } - let totalBytes = 0; - let didExceedMaxBytes = false; - const reader = response.body.getReader(); - const chunks: Uint8Array[] = []; - // eslint-disable-next-line no-constant-condition - while (true) { - // eslint-disable-next-line no-await-in-loop - const { done, value } = await reader.read(); - if (done) { - break; - } - totalBytes += value.length; - chunks.push(value); - if (totalBytes >= maxBytes) { - didExceedMaxBytes = true; - abortController.abort(); - break; - } - } - if (didExceedMaxBytes) { - const error = new Error("Maximum file size exceeded"); - error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED; - throw error; - } - const blob = new Blob(chunks); - const arrayBuffer = await blob.arrayBuffer(); - const decoder = new TextDecoder(); - return decoder.decode(arrayBuffer); -} diff --git a/src/app/api/remotes/[encodedRemoteSpecification]/route.ts b/src/app/api/remotes/[encodedRemoteSpecification]/route.ts deleted file mode 100644 index 94362cf2..00000000 --- a/src/app/api/remotes/[encodedRemoteSpecification]/route.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { NextRequest, NextResponse } from "next/server" -import { session } from "@/composition" -import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" -import { privateDecrypt, constants } from 'crypto' -import { z } from 'zod'; -import { downloadFile } from "./downloadFile"; -import { checkIfJsonOrYaml } from "./checkIfJsonOrYaml"; - -export const ErrorName = { - MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", - TIMEOUT: "TimeoutError", - NOT_JSON_OR_YAML: "NotJsonOrYamlError", -} - -interface RemoteSpecificationParams { - encodedRemoteSpecification: string -} - -const AuthSchema = z.object({ - type: z.string(), - username: z.string(), - password: z.string(), -}); -type Auth = z.infer; - -const RemoteSpecificationSchema = z.object({ - url: z.string().url(), - auth: AuthSchema.optional(), -}); -type RemoteSpecification = z.infer; - -const privateKey = `-----BEGIN PRIVATE KEY----- -MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDPvqSIXsEJIHro -Mw3Lsxmq91LVVBCEnIYZO4rVBk2mdA7FADwqfv/2T0tA49eYd8RcZp8CHWDmK36a -OVsN3sMy3Lh7YwaIFqOAyD6gm1hPlpb0oJE4U3a9aZXh9WnaWepCKNHJhxSWhlL2 -6h+miv8vblSFvG+6v0FVbh4obXDMbp/ruf+BHZEns+KMczkQp5HaNHLRVoeW76n8 -E473cM4kdSeJBSJmcSdhMB+b0P1QypozbJxqh+tztMPdqf9w58QKFE4KTqyVE/pw -RnLCBN31YaD6CL3VtGGHJdZwKqLfEWfnRvWBu87HOJHwVXCTAW8osbLXI5SdZAfJ -bTEcSaXTAgMBAAECggEAAaf9zQZXwg8NJAn68pm0FkJc0geqFmlqQjaxy2ISBvSq -o+bS8RAnl7UdZphuTJ7hhAEhj+H3Wa3ufCfLc1DMHu2Qw+yELtB6Do8lSG6CMkaK -8z95jcLrnWwuAx5AH6tmgodtglCHA9r8t+pf+zEyzBvDzEHB+FaBhVJ5i3CaOgnn -sldRDFXPbIK5vp9znNmJiCttdFh4o1zClVybH+GdDXERlal9zBAdOGR9RQsW5Ps8 -rEldFdInFdW2Jwzg1Q2AMu0+uxdpGDX2s//jp6q99W4VZFrq1NAoqfGDAaRePJ23 -w5K1qoLTXF+IkvBRK749SznUcZyE4ZmLHJoKCVjMYQKBgQDxulf0fw1h1+jQ9OmC -Dim8UV82WyjOknDyhpdpHauhcPQQwMDgF6ugZA5H5HZRAnjWNctEcQ72V+ET/6eU -9SUmvnI6z+gd/eJGGKQxkNoTaWkkRqc6RSBwiVUc1Fv3x0nqhtd65VGIi6i6psUn -gnLvMI1QxANtcgXdb3qp1pBAGQKBgQDcAqbJ/5BgiYGgef/QoyRTI6M8ZQWJ4lf9 -xrYzY+96T8JNEBshFu3gqCOclcV8jS21BTbWudpyhDradmdRRsypYTJh0HBxfzd2 -gXsMZ27jDMQ8q91xhgaziEGTVKrx/02dHJaLUza6MqkfVVyAzWDx0Hu6sN5Qxe4D -8bBSmO+iywKBgC0t7+yBpqWn7hrH+7DUJtbMuqf1J85cLoIVx8zcv8xfyS4saKA5 -rFlA+i5TtA12EdGvojs7illemXHccZz0qKnyJHV7kF2yqw0A5AdjlG7WX9Fo5y6L -5wFBmcfWpQ3NkLIl27Zbj/6eY73nF6hHyGWORItY528YRaJaiKmfsbxZAoGBAJUW -6uW53KG+rOwNoHBHDaeVX9neb2lny876aJ/cmf0drYLBZlD/E8YIytEioUhs90tT -ND1AhqrRtnwyfoMSYkBp0FV+haQz3GbfCX53XSpZjWW75X03oLTqod1wI8OICZVt -OQtDIbP9/qNwGhZils5nRGFX19+OsWNU1fKzFrkPAoGARA4wcLbshkxX6KGhfE5x -LB1apVkCG0y6jUAq4469svfpeILUtqgNBMBWYa9O7ID5NSfRn5DjyEl9U3SYz0H4 -psXP4eE1x86Lz37lAuXPpnrtE93xiRVFMYB3KXdyeGlTpwKLSGbf8pex85yrQU42 -CKpLoFTucpZ0hyD+upn2KMI= ------END PRIVATE KEY-----` - -export async function GET(req: NextRequest, { params }: { params: RemoteSpecificationParams }) { - const isAuthenticated = await session.getIsAuthenticated() - if (!isAuthenticated) { - return makeUnauthenticatedAPIErrorResponse() - } - - const encryptedRemoteSpecification = decodeURIComponent(params.encodedRemoteSpecification) - - // Decrypt the encrypted specification - const decryptedRemoteSpecification = privateDecrypt( - { - key: privateKey, - padding: constants.RSA_PKCS1_OAEP_PADDING, - oaepHash: 'sha256' - }, - Buffer.from(encryptedRemoteSpecification, 'base64') - ).toString('utf-8') - - const remoteSpecification: RemoteSpecification = RemoteSpecificationSchema.parse(JSON.parse(decryptedRemoteSpecification)) - - console.log(remoteSpecification) - - let url: URL - try { - url = new URL(remoteSpecification.url) - } catch { - return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") - } - try { - const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES")) - const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS")) - const maxBytes = maxMegabytes * 1024 * 1024 - const fileText = await downloadFile({ url, maxBytes, timeoutInSeconds, basicAuthUsername: remoteSpecification.auth?.username, basicAuthPassword: remoteSpecification.auth?.password }) - checkIfJsonOrYaml(fileText) - return new NextResponse(fileText, { status: 200, headers: { "Content-Type": "text/plain" } }) - } catch (error) { - if (error instanceof Error == false) { - return makeAPIErrorResponse(500, "An unknown error occurred.") - } - if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) { - return makeAPIErrorResponse(413, "The operation was aborted.") - } else if (error.name === ErrorName.TIMEOUT) { - return makeAPIErrorResponse(408, "The operation timed out.") - } else if (error.name === ErrorName.NOT_JSON_OR_YAML) { - return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.") - } else { - return makeAPIErrorResponse(500, error.message) - } - } -} diff --git a/src/app/api/remotes/[encryptedRemoteConfig]/route.ts b/src/app/api/remotes/[encryptedRemoteConfig]/route.ts new file mode 100644 index 00000000..c0477992 --- /dev/null +++ b/src/app/api/remotes/[encryptedRemoteConfig]/route.ts @@ -0,0 +1,72 @@ +import { NextRequest, NextResponse } from "next/server" +import { encryptionService, session } from "@/composition" +import { env, makeAPIErrorResponse, makeUnauthenticatedAPIErrorResponse } from "@/common" +import { z } from 'zod'; +import { downloadFile, checkIfJsonOrYaml, ErrorName } from "@/common/utils/fileUtils"; + +interface RemoteSpecificationParams { + encryptedRemoteConfig: string // encrypted and URL encoded JSON string +} + +const RemoteSpecAuthSchema = z.object({ + type: z.string(), + username: z.string(), + password: z.string(), +}); +type RemoteSpecAuth = z.infer; + +const RemoteConfigSchema = z.object({ + url: z.string().url(), + auth: RemoteSpecAuthSchema.optional(), +}); +type RemoteConfig = z.infer; + +export async function GET(req: NextRequest, { params }: { params: RemoteSpecificationParams }) { + const isAuthenticated = await session.getIsAuthenticated() + if (!isAuthenticated) { + return makeUnauthenticatedAPIErrorResponse() + } + + const decodedEncryptedRemoteConfig = decodeURIComponent(params.encryptedRemoteConfig) + + const decryptedRemoteConfig = encryptionService.decrypt(decodedEncryptedRemoteConfig) + + const remoteConfig = RemoteConfigSchema.parse(JSON.parse(decryptedRemoteConfig)) + + let url: URL + try { + url = new URL(remoteConfig.url) + } catch { + return makeAPIErrorResponse(400, "Invalid \"url\" query parameter.") + } + try { + const maxMegabytes = Number(env.getOrThrow("PROXY_API_MAXIMUM_FILE_SIZE_IN_MEGABYTES")) + const timeoutInSeconds = Number(env.getOrThrow("PROXY_API_TIMEOUT_IN_SECONDS")) + const maxBytes = maxMegabytes * 1024 * 1024 + + const fileText = await downloadFile({ + url, + maxBytes, + timeoutInSeconds, + basicAuthUsername: remoteConfig.auth?.username, + basicAuthPassword: remoteConfig.auth?.password + }) + + checkIfJsonOrYaml(fileText) + + return new NextResponse(fileText, { status: 200, headers: { "Content-Type": "text/plain" } }) + } catch (error) { + if (error instanceof Error == false) { + return makeAPIErrorResponse(500, "An unknown error occurred.") + } + if (error.name === ErrorName.MAX_FILE_SIZE_EXCEEDED) { + return makeAPIErrorResponse(413, "The operation was aborted.") + } else if (error.name === ErrorName.TIMEOUT) { + return makeAPIErrorResponse(408, "The operation timed out.") + } else if (error.name === ErrorName.NOT_JSON_OR_YAML) { + return makeAPIErrorResponse(400, "Url does not point to a JSON or YAML file.") + } else { + return makeAPIErrorResponse(500, error.message) + } + } +} diff --git a/src/common/encryption/EncryptionService.ts b/src/common/encryption/EncryptionService.ts new file mode 100644 index 00000000..9a58ba4d --- /dev/null +++ b/src/common/encryption/EncryptionService.ts @@ -0,0 +1,42 @@ +import { publicEncrypt, privateDecrypt, constants } from 'crypto'; + +interface IEncryptionService { + encrypt(data: string): string; + decrypt(encryptedDataBase64: string): string; +} + +class RsaEncryptionService implements IEncryptionService { + private publicKey: string; + private privateKey: string; + + constructor({ publicKey, privateKey }: { publicKey: string; privateKey: string }) { + this.publicKey = publicKey; + this.privateKey = privateKey; + } + + encrypt(data: string): string { + const buffer = Buffer.from(data, 'utf-8'); + const encrypted = publicEncrypt( + { + key: this.publicKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + buffer + ); + return encrypted.toString('base64'); + } + + decrypt(encryptedDataBase64: string): string { + return privateDecrypt( + { + key: this.privateKey, + padding: constants.RSA_PKCS1_OAEP_PADDING, + oaepHash: 'sha256' + }, + Buffer.from(encryptedDataBase64, 'base64') + ).toString('utf-8') + } +} + +export default RsaEncryptionService; diff --git a/src/common/utils/fileUtils.ts b/src/common/utils/fileUtils.ts new file mode 100644 index 00000000..8308e1f9 --- /dev/null +++ b/src/common/utils/fileUtils.ts @@ -0,0 +1,74 @@ +import { parse as parseYaml } from "yaml" + +export const ErrorName = { + MAX_FILE_SIZE_EXCEEDED: "MaxFileSizeExceededError", + TIMEOUT: "TimeoutError", + NOT_JSON_OR_YAML: "NotJsonOrYamlError", +} + +export async function downloadFile(params: { + url: URL; + maxBytes: number; + timeoutInSeconds: number; + basicAuthUsername?: string; + basicAuthPassword?: string; +}): Promise { + const { url, maxBytes, timeoutInSeconds, basicAuthUsername, basicAuthPassword } = params; + const abortController = new AbortController(); + const timeoutSignal = AbortSignal.timeout(timeoutInSeconds * 1000); + const headers: { [key: string]: string; } = {}; + // Extract basic auth from URL and construct an Authorization header instead. + if (basicAuthUsername && basicAuthPassword) { + headers["Authorization"] = "Basic " + btoa(`${basicAuthUsername}:${basicAuthPassword}`); + } + // Make sure basic auth is removed from URL. + const urlWithoutAuth = url; + urlWithoutAuth.username = ""; + urlWithoutAuth.password = ""; + const response = await fetch(urlWithoutAuth, { + method: "GET", + headers, + signal: AbortSignal.any([abortController.signal, timeoutSignal]) + }); + if (!response.body) { + throw new Error("Response body unavailable"); + } + let totalBytes = 0; + let didExceedMaxBytes = false; + const reader = response.body.getReader(); + const chunks: Uint8Array[] = []; + // eslint-disable-next-line no-constant-condition + while (true) { + // eslint-disable-next-line no-await-in-loop + const { done, value } = await reader.read(); + if (done) { + break; + } + totalBytes += value.length; + chunks.push(value); + if (totalBytes >= maxBytes) { + didExceedMaxBytes = true; + abortController.abort(); + break; + } + } + if (didExceedMaxBytes) { + const error = new Error("Maximum file size exceeded"); + error.name = ErrorName.MAX_FILE_SIZE_EXCEEDED; + throw error; + } + const blob = new Blob(chunks); + const arrayBuffer = await blob.arrayBuffer(); + const decoder = new TextDecoder(); + return decoder.decode(arrayBuffer); +} + +export function checkIfJsonOrYaml(fileText: string) { + try { + parseYaml(fileText) // will also parse JSON as it is a subset of YAML + } catch { + const error = new Error("File is not JSON or YAML") + error.name = ErrorName.NOT_JSON_OR_YAML + throw error + } +} diff --git a/src/composition.ts b/src/composition.ts index 8514a16b..af18122b 100644 --- a/src/composition.ts +++ b/src/composition.ts @@ -51,6 +51,7 @@ import { PullRequestCommenter } from "@/features/hooks/domain" import { RepoRestrictedGitHubClient } from "./common/github/RepoRestrictedGitHubClient" +import RsaEncryptionService from "./common/encryption/EncryptionService" const gitHubAppCredentials = { appId: env.getOrThrow("GITHUB_APP_ID"), @@ -219,4 +220,9 @@ export const gitHubHookHandler = new GitHubHookHandler({ }) }) }) -}) \ No newline at end of file +}) + +export const encryptionService = new RsaEncryptionService({ + publicKey: Buffer.from(env.getOrThrow("ENCRYPTION_PUBLIC_KEY_BASE_64"), "base64").toString("utf-8"), + privateKey: Buffer.from(env.getOrThrow("ENCRYPTION_PRIVATE_KEY_BASE_64"), "base64").toString("utf-8") +}) From 82869a17c23e1e449641228103bd1189fb03134e Mon Sep 17 00:00:00 2001 From: Ulrik Andersen Date: Wed, 4 Dec 2024 14:02:06 +0100 Subject: [PATCH 03/32] Add page for encrypting with public key --- src/app/(authed)/encrypt/action.ts | 7 ++++ src/app/(authed)/encrypt/layout.tsx | 20 +++++++++++ src/app/(authed)/encrypt/page.tsx | 53 +++++++++++++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 src/app/(authed)/encrypt/action.ts create mode 100644 src/app/(authed)/encrypt/layout.tsx create mode 100644 src/app/(authed)/encrypt/page.tsx diff --git a/src/app/(authed)/encrypt/action.ts b/src/app/(authed)/encrypt/action.ts new file mode 100644 index 00000000..ed1161c1 --- /dev/null +++ b/src/app/(authed)/encrypt/action.ts @@ -0,0 +1,7 @@ +'use server' + +import { encryptionService } from '@/composition' + +export async function encrypt(text: string): Promise { + return encryptionService.encrypt(text) +} diff --git a/src/app/(authed)/encrypt/layout.tsx b/src/app/(authed)/encrypt/layout.tsx new file mode 100644 index 00000000..d4114d0c --- /dev/null +++ b/src/app/(authed)/encrypt/layout.tsx @@ -0,0 +1,20 @@ +import { Box, Stack } from "@mui/material" +import SecondarySplitHeader from "@/features/sidebar/view/SecondarySplitHeader" + +export default function Page({ children }: { children: React.ReactNode }) { + return ( + + + + {children} + + + ) +} \ No newline at end of file diff --git a/src/app/(authed)/encrypt/page.tsx b/src/app/(authed)/encrypt/page.tsx new file mode 100644 index 00000000..bb5fddb1 --- /dev/null +++ b/src/app/(authed)/encrypt/page.tsx @@ -0,0 +1,53 @@ +'use client' + +import { useState } from 'react' +import { encrypt } from './action' +import { Box, Button, TextareaAutosize, Typography } from '@mui/material' + +export default function EncryptPage() { + const [inputText, setInputText] = useState('') + const [encryptedText, setEncryptedText] = useState('') + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault() + const encrypted = await encrypt(inputText) + setEncryptedText(encrypted) + } + + return ( + + + Encrypt text for remote config + + + Use this to encrypt values to be used in the configuration file.
+ The input text is encrypted using the public key of the server. +
+
+ setInputText(e.target.value)} + cols={50} + minRows={10} + /> +
+ + + {encryptedText && ( +