diff --git a/api/migrations/57-hedgedoc-password-entry-in-settings.sql b/api/migrations/57-hedgedoc-password-entry-in-settings.sql new file mode 100644 index 000000000..6b2d1d0f4 --- /dev/null +++ b/api/migrations/57-hedgedoc-password-entry-in-settings.sql @@ -0,0 +1,5 @@ +ALTER TABLE ctfnote.settings + ADD COLUMN "hedgedoc_meta_user_password" VARCHAR(128); + +GRANT SELECT ("hedgedoc_meta_user_password") ON ctfnote.settings TO user_postgraphile; +GRANT UPDATE ("hedgedoc_meta_user_password") ON ctfnote.settings TO user_postgraphile; \ No newline at end of file diff --git a/api/src/config.ts b/api/src/config.ts index c97f8dfad..ac66693da 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -34,6 +34,8 @@ export type CTFNoteConfig = DeepReadOnly<{ documentMaxLength: number; domain: string; useSSL: string; + nonpublicPads: boolean; + metaUserName: string; }; web: { @@ -90,6 +92,8 @@ const config: CTFNoteConfig = { documentMaxLength: Number(getEnv("CMD_DOCUMENT_MAX_LENGTH", "100000")), domain: getEnv("CMD_DOMAIN", ""), useSSL: getEnv("CMD_PROTOCOL_USESSL", "false"), + metaUserName: "CTFNote_Bot@ctf0.de", //has to be an email address + nonpublicPads: Boolean(getEnv("CMD_NON_PUBLIC_PADS", "false")), //TODO check if parsing works here correctly }, web: { port: getEnvInt("WEB_PORT"), diff --git a/api/src/discord/utils/messages.ts b/api/src/discord/utils/messages.ts index 36d4e58cb..fae9bcebe 100644 --- a/api/src/discord/utils/messages.ts +++ b/api/src/discord/utils/messages.ts @@ -14,6 +14,7 @@ import { Task, getTaskFromId } from "../database/tasks"; import { CTF, getCtfFromDatabase } from "../database/ctfs"; import { challengesTalkChannelName, getTaskChannel } from "../agile/channels"; import { createPad } from "../../plugins/createTask"; +import { registerAndLoginUser } from "../..//utils/hedgedoc"; export const discordArchiveTaskName = "Discord archive"; @@ -245,6 +246,8 @@ export async function createPadWithoutLimit( let currentPadLength = 0; let padIndex = 1; + const cookie = await registerAndLoginUser(); + for (const message of messages) { const messageLength = message.length; @@ -252,6 +255,7 @@ export async function createPadWithoutLimit( if (currentPadLength + messageLength > MAX_PAD_LENGTH) { // Create a new pad const padUrl = await createPad( + cookie, `${ctfTitle} ${discordArchiveTaskName} (${padIndex})`, currentPadMessages.join("\n") ); @@ -272,6 +276,7 @@ export async function createPadWithoutLimit( if (pads.length > 0) { // Create the final pad for the remaining messages const padUrl = await createPad( + cookie, `${ctfTitle} ${discordArchiveTaskName} (${padIndex})`, currentPadMessages.join("\n") ); @@ -286,6 +291,7 @@ export async function createPadWithoutLimit( } return await createPad( + cookie, `${ctfTitle} ${discordArchiveTaskName}`, firstPadContent ); diff --git a/api/src/index.ts b/api/src/index.ts index 483e4e3fc..5c537f5ae 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -22,6 +22,8 @@ import discordHooks from "./discord/hooks"; import { initDiscordBot } from "./discord"; import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many"; import ProfileSubscriptionPlugin from "./plugins/ProfileSubscriptionPlugin"; +import hedgedocAuth from "./plugins/hedgedocAuth"; +import { initHedgedocPassword } from "./utils/hedgedoc"; function getDbUrl(role: "user" | "admin") { const login = config.db[role].login; @@ -63,10 +65,17 @@ function createOptions() { discordHooks, PgManyToManyPlugin, ProfileSubscriptionPlugin, + hedgedocAuth, ], ownerConnectionString: getDbUrl("admin"), enableQueryBatching: true, legacyRelations: "omit" as const, + async additionalGraphQLContextFromRequest(req, res) { + return { + setHeader: (name: string, value: string | number) => + res.setHeader(name, value), + }; + }, }; if (config.env == "development") { @@ -154,6 +163,10 @@ async function main() { await initDiscordBot(); + if (config.pad.nonpublicPads) { + await initHedgedocPassword(); + } + app.listen(config.web.port, () => { //sendMessageToDiscord("CTFNote API started"); console.log(`Listening on :${config.web.port}`); diff --git a/api/src/plugins/createTask.ts b/api/src/plugins/createTask.ts index 628fd7881..9b9f7e83a 100644 --- a/api/src/plugins/createTask.ts +++ b/api/src/plugins/createTask.ts @@ -2,6 +2,7 @@ import { makeExtendSchemaPlugin, gql } from "graphile-utils"; import axios from "axios"; import savepointWrapper from "./savepointWrapper"; import config from "../config"; +import { registerAndLoginUser } from "../utils/hedgedoc"; function buildNoteContent( title: string, @@ -32,6 +33,7 @@ function buildNoteContent( } export async function createPad( + setCookieHeader: string[], title: string, description?: string, tags?: string[] @@ -39,6 +41,7 @@ export async function createPad( const options = { headers: { "Content-Type": "text/markdown", + Cookie: setCookieHeader.join("; "), }, maxRedirects: 0, @@ -92,49 +95,65 @@ export default makeExtendSchemaPlugin((build) => { { pgClient }, resolveInfo ) => { - const { - rows: [isAllowed], - } = await pgClient.query(`SELECT ctfnote_private.can_play_ctf($1)`, [ - ctfId, - ]); - - if (isAllowed.can_play_ctf !== true) { - return {}; - } - - const padPathOrUrl = await createPad(title, description, tags); - - let padPath: string; - if (padPathOrUrl.startsWith("/")) { - padPath = padPathOrUrl.slice(1); - } else { - padPath = new URL(padPathOrUrl).pathname.slice(1); - } - - const padUrl = `${config.pad.showUrl}${padPath}`; + try { + let cookie: string[] | undefined = undefined; + if (config.pad.nonpublicPads) { + cookie = await registerAndLoginUser(); + } - return await savepointWrapper(pgClient, async () => { const { - rows: [newTask], + rows: [isAllowed], } = await pgClient.query( - `SELECT * FROM ctfnote_private.create_task($1, $2, $3, $4, $5)`, - [title, description ?? "", flag ?? "", padUrl, ctfId] + `SELECT ctfnote_private.can_play_ctf($1)`, + [ctfId] ); - const [row] = - await resolveInfo.graphile.selectGraphQLResultFromTable( - sql.fragment`ctfnote.task`, - (tableAlias, queryBuilder) => { - queryBuilder.where( - sql.fragment`${tableAlias}.id = ${sql.value(newTask.id)}` - ); - } - ); - return { - data: row, - query: build.$$isQuery, - }; - }); + if (isAllowed.can_play_ctf !== true) { + return {}; + } + + const padPathOrUrl = await createPad( + cookie ?? [], + title, + description, + tags + ); + + let padPath: string; + if (padPathOrUrl.startsWith("/")) { + padPath = padPathOrUrl.slice(1); + } else { + padPath = new URL(padPathOrUrl).pathname.slice(1); + } + + const padUrl = `${config.pad.showUrl}${padPath}`; + + return await savepointWrapper(pgClient, async () => { + const { + rows: [newTask], + } = await pgClient.query( + `SELECT * FROM ctfnote_private.create_task($1, $2, $3, $4, $5)`, + [title, description ?? "", flag ?? "", padUrl, ctfId] + ); + const [row] = + await resolveInfo.graphile.selectGraphQLResultFromTable( + sql.fragment`ctfnote.task`, + (tableAlias, queryBuilder) => { + queryBuilder.where( + sql.fragment`${tableAlias}.id = ${sql.value(newTask.id)}` + ); + } + ); + + return { + data: row, + query: build.$$isQuery, + }; + }); + } catch (error) { + console.error("error:", error); + throw new Error(`Creating task failed: ${error}`); + } }, }, }, diff --git a/api/src/plugins/hedgedocAuth.ts b/api/src/plugins/hedgedocAuth.ts new file mode 100644 index 000000000..3aef10b91 --- /dev/null +++ b/api/src/plugins/hedgedocAuth.ts @@ -0,0 +1,116 @@ +import { makeWrapResolversPlugin } from "graphile-utils"; +import axios from "axios"; +import querystring from "querystring"; + +class HedgedocAuth { + private static async baseUrl(): Promise { + let cleanUrl: string = + process.env.CREATE_PAD_URL || "http://hedgedoc:3000/new"; + cleanUrl = cleanUrl.slice(0, -4); //remove '/new' for clean url + return cleanUrl; + } + + private static async authPad( + username: string, + password: string, + url: URL + ): Promise { + let domain: string; + //if domain does not end in '.[tld]', it will be rejected + //so we add '.local' manually + if (url.hostname.split(".").length == 1) { + domain = `${url.hostname}.local`; + } else { + domain = url.hostname; + } + + const email = `${username}@${domain}`; + + try { + const res = await axios.post( + url.toString(), + querystring.stringify({ + email: email, + password: password, + }), + { + validateStatus: (status) => status === 302, + maxRedirects: 0, + timeout: 5000, + headers: { + "Content-Type": "application/x-www-form-urlencoded", + }, + } + ); + return (res.headers["set-cookie"] ?? [""])[0].replace("HttpOnly;", ""); + } catch (e) { + console.error(e); + return ""; + } + } + + static async register(username: string, password: string): Promise { + const authUrl = new URL(`${await this.baseUrl()}/register`); + return this.authPad(username, password, authUrl); + } + + static async verifyLogin(cookie: string) { + const url = new URL(`${await this.baseUrl()}/me`); + + try { + const res = await axios.get(url.toString(), { + validateStatus: (status) => status === 200, + maxRedirects: 0, + timeout: 5000, + headers: { + Cookie: cookie, + }, + }); + + return res.data.status == "ok"; + } catch (e) { + return false; + } + } + + static async login(username: string, password: string): Promise { + const authUrl = new URL(`${await this.baseUrl()}/login`); + const result = await this.authPad(username, password, authUrl); + const success = await this.verifyLogin(result); + if (!success) { + //create account for existing users that are not registered to Hedgedoc + await this.register(username, password); + return this.authPad(username, password, authUrl); + } else { + return result; + } + } +} + +export default makeWrapResolversPlugin({ + Mutation: { + login: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async resolve(resolve: any, _source, args, context: any) { + const result = await resolve(); + context.setHeader( + "set-cookie", + await HedgedocAuth.login(args.input.login, args.input.password) + ); + return result; + }, + }, + register: { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + async resolve(resolve: any, _source, args, context: any) { + const result = await resolve(); + await HedgedocAuth.register(args.input.login, args.input.password); + context.setHeader( + "set-cookie", + await HedgedocAuth.login(args.input.login, args.input.password) + ); + return result; + }, + }, + }, +}); diff --git a/api/src/utils/hedgedoc.ts b/api/src/utils/hedgedoc.ts new file mode 100644 index 000000000..c6062d0b7 --- /dev/null +++ b/api/src/utils/hedgedoc.ts @@ -0,0 +1,105 @@ +import axios from "axios"; +import { connectToDatabase } from "./database"; +import config from "../config"; + +export async function getPassword(): Promise { + const pgClient = await connectToDatabase(); + try { + const result = await pgClient.query( + "SELECT hedgedoc_meta_user_password FROM ctfnote.settings LIMIT 1" + ); + if (result.rows.length > 0) { + return result.rows[0].hedgedoc_meta_user_password || null; + } + return null; + } catch (error) { + console.error("Failed to get HedgeDoc password:", error); + throw error; + } finally { + pgClient.release(); + } +} + +export async function initHedgedocPassword(): Promise { + const pgClient = await connectToDatabase(); + const pw = await getPassword(); + + if (pw !== null) { + return pw; + } + + const password = [...Array(128)] + .map(() => + String.fromCharCode(Math.floor(Math.random() * (126 - 33 + 1)) + 33) + ) + .join(""); + + try { + const query = + "UPDATE ctfnote.settings SET hedgedoc_meta_user_password = $1"; + const values = [password]; + await pgClient.query(query, values); + return password; + } catch (error) { + console.error( + "Failed to set hedgedoc_nonpublic_pads flag in the database:", + error + ); + throw error; + } finally { + pgClient.release(); + } +} + +export async function registerAndLoginUser(): Promise { + const username = config.pad.metaUserName; + const password = getPassword(); //TODO cache somehow + + try { + await axios.post( + "http://hedgedoc:3000/register", + `email=${username}&password=${password}`, + { + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Content-Type": "application/x-www-form-urlencoded", + Priority: "u=4", + }, + withCredentials: true, + maxRedirects: 0, + validateStatus: (status: number) => + status === 200 || status === 409 || status === 302, // 409 if already exists + } + ); + + const loginResponse = await axios.post( + "http://hedgedoc:3000/login", + `email=${username}&password=${password}`, + { + headers: { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64; rv:143.0) Gecko/20100101 Firefox/143.0", + Accept: + "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "en-US,en;q=0.5", + "Content-Type": "application/x-www-form-urlencoded", + Priority: "u=4", + }, + withCredentials: true, + maxRedirects: 0, + validateStatus: (status: number) => status === 200 || status === 302, + } + ); + + const setCookieHeader = loginResponse.headers["set-cookie"]; + return setCookieHeader ?? []; + } catch (error) { + throw Error( + `Login to hedgedoc during task creation failed. Error: ${error}` + ); + } +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 3decc981d..a96d5f906 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -22,6 +22,7 @@ services: CMD_IMAGE_UPLOAD_TYPE: "filesystem" CMD_CSP_ENABLE: "false" CMD_RATE_LIMIT_NEW_NOTES: "0" + CMD_ALLOW_ANONYMOUS: false depends_on: - db restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 0624509f6..47163f12b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,7 @@ services: DISCORD_REGISTRATION_ENABLED: ${DISCORD_REGISTRATION_ENABLED:-false} DISCORD_REGISTRATION_CTFNOTE_ROLE: ${DISCORD_REGISTRATION_CTFNOTE_ROLE} DISCORD_REGISTRATION_ROLE_ID: ${DISCORD_REGISTRATION_ROLE_ID} + CMD_NON_PUBLIC_PADS: true TZ: ${TZ:-UTC} LC_ALL: ${LC_ALL:-en_US.UTF-8} SESSION_SECRET: ${SESSION_SECRET:-} @@ -70,6 +71,7 @@ services: - CMD_CSP_ENABLE=${CMD_CSP_ENABLE:-false} - CMD_IMAGE_UPLOAD_TYPE=${CMD_IMAGE_UPLOAD_TYPE:-imgur} - CMD_DOCUMENT_MAX_LENGTH=${CMD_DOCUMENT_MAX_LENGTH:-100000} + - CMD_ALLOW_ANONYMOUS=false depends_on: - db restart: unless-stopped diff --git a/front/nginx.conf b/front/nginx.conf index 9ac8eb36f..8966825cd 100644 --- a/front/nginx.conf +++ b/front/nginx.conf @@ -49,6 +49,11 @@ server { add_header Pragma "no-cache"; } + # Forbid registration of new users via Hedgedoc from outside docker network + location = /pad/register { + deny all; + } + # Due to the CSP of Hedgedoc, we need to serve the hotkeys-iframe.js file from here to allow execution location /pad/js/hotkeys-iframe.js { root /usr/share/nginx/html; diff --git a/front/src/boot/ctfnote.ts b/front/src/boot/ctfnote.ts index 51a1ed374..6451ec675 100644 --- a/front/src/boot/ctfnote.ts +++ b/front/src/boot/ctfnote.ts @@ -27,6 +27,8 @@ export default boot(async ({ router, redirect, urlPath }) => { } catch { ctfnote.auth.saveJWT(null); window.location.reload(); + document.cookie = + 'connect.sid' + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; } const prefetchs = [ctfnote.settings.prefetchSettings()]; @@ -37,5 +39,7 @@ export default boot(async ({ router, redirect, urlPath }) => { await Promise.all(prefetchs); if (!logged && !route.meta?.public) { redirect({ name: 'auth-login' }); + document.cookie = + 'connect.sid' + '=; expires=Thu, 01 Jan 1970 00:00:00 GMT'; } }); diff --git a/front/src/components/Auth/Login.vue b/front/src/components/Auth/Login.vue index 1c990aa07..dd298ccb2 100644 --- a/front/src/components/Auth/Login.vue +++ b/front/src/components/Auth/Login.vue @@ -15,6 +15,11 @@ label="Username" required autofocus + :rules="[ + (val) => (val && val.length > 0) || 'Please type something', + (val) => (val && val.indexOf('@') === -1) || 'Please dont use @', + ]" + hint="Your username is visible to other members" /> diff --git a/front/src/components/Dialogs/TaskEditDialog.vue b/front/src/components/Dialogs/TaskEditDialog.vue index 0c096aeb4..ad27c9733 100644 --- a/front/src/components/Dialogs/TaskEditDialog.vue +++ b/front/src/components/Dialogs/TaskEditDialog.vue @@ -114,6 +114,7 @@ export default defineComponent({ createTask: ctfnote.tasks.useCreateTask(), addTagsForTask: ctfnote.tags.useAddTagsForTask(), notifySuccess: ctfnote.ui.useNotify().notifySuccess, + notifyFail: ctfnote.ui.useNotify().notifyError, tags, suggestions, filterFn, @@ -136,11 +137,20 @@ export default defineComponent({ this.notifySuccess({ message: `Task ${this.form.title} is being created...`, }); - const r = await this.createTask(this.ctfId, this.form); + try { + const r = await this.createTask(this.ctfId, this.form).catch((e) => { + console.error('error creating task:', e); + this.notifyFail( + `Error creating task ${this.form.title}: ${(e as Error).message ?? ''}) `, + ); + }); - task = r?.data?.createTask?.task; - if (task) { - await this.addTagsForTask(this.form.tags, makeId(task.id)); + task = r?.data?.createTask?.task; + if (task) { + await this.addTagsForTask(this.form.tags, makeId(task.id)); + } + } catch (e) { + console.error('error creating task:', e); } } else if (this.task) { this.notifySuccess({ diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..cc6cb86d0 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,32 @@ +{ + "name": "ctfnote", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ctfnote", + "version": "1.0.0", + "license": "GPL-3.0", + "devDependencies": { + "husky": "^8.0.3" + } + }, + "node_modules/husky": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/husky/-/husky-8.0.3.tgz", + "integrity": "sha512-+dQSyqPh4x1hlO1swXBiNb2HzTDN1I2IGLQx1GrBuiqFJfoMrnZWwVmatvSiO+Iz8fBUnf+lekwNo4c2LlXItg==", + "dev": true, + "license": "MIT", + "bin": { + "husky": "lib/bin.js" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/typicode" + } + } + } +}