From 9fa4ab85ba927473ed3528833f33ad616dd153e9 Mon Sep 17 00:00:00 2001 From: Philipp Date: Tue, 7 Oct 2025 21:03:07 +0200 Subject: [PATCH 01/11] Port of: https://github.com/JJ-8/CTFNote/pull/3 --- api/src/index.ts | 8 ++ api/src/plugins/hedgedocAuth.ts | 116 ++++++++++++++++++++++++++++ front/src/boot/ctfnote.ts | 4 + front/src/components/Auth/Login.vue | 5 ++ package-lock.json | 32 ++++++++ 5 files changed, 165 insertions(+) create mode 100644 api/src/plugins/hedgedocAuth.ts create mode 100644 package-lock.json diff --git a/api/src/index.ts b/api/src/index.ts index 483e4e3fc..de82a148b 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -22,6 +22,7 @@ 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"; function getDbUrl(role: "user" | "admin") { const login = config.db[role].login; @@ -63,10 +64,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") { 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/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/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" + } + } + } +} From 25f951120e93ad8d778ce32ca88a23d1686b4258 Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 01:15:37 +0200 Subject: [PATCH 02/11] first experiments with meta user that creates non public pads --- ...57-hedgedoc-password-entry-in-settings.sql | 5 + api/src/discord/utils/messages.ts | 3 + api/src/plugins/createTask.ts | 144 +++++++++++++----- docker-compose.yml | 2 + 4 files changed, 118 insertions(+), 36 deletions(-) create mode 100644 api/migrations/57-hedgedoc-password-entry-in-settings.sql 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/discord/utils/messages.ts b/api/src/discord/utils/messages.ts index 36d4e58cb..540a7e1fb 100644 --- a/api/src/discord/utils/messages.ts +++ b/api/src/discord/utils/messages.ts @@ -252,6 +252,7 @@ export async function createPadWithoutLimit( if (currentPadLength + messageLength > MAX_PAD_LENGTH) { // Create a new pad const padUrl = await createPad( + [], `${ctfTitle} ${discordArchiveTaskName} (${padIndex})`, currentPadMessages.join("\n") ); @@ -272,6 +273,7 @@ export async function createPadWithoutLimit( if (pads.length > 0) { // Create the final pad for the remaining messages const padUrl = await createPad( + [], `${ctfTitle} ${discordArchiveTaskName} (${padIndex})`, currentPadMessages.join("\n") ); @@ -286,6 +288,7 @@ export async function createPadWithoutLimit( } return await createPad( + [], `${ctfTitle} ${discordArchiveTaskName}`, firstPadContent ); diff --git a/api/src/plugins/createTask.ts b/api/src/plugins/createTask.ts index 628fd7881..be6231743 100644 --- a/api/src/plugins/createTask.ts +++ b/api/src/plugins/createTask.ts @@ -32,6 +32,7 @@ function buildNoteContent( } export async function createPad( + setCookieHeader: string[], title: string, description?: string, tags?: string[] @@ -39,6 +40,7 @@ export async function createPad( const options = { headers: { "Content-Type": "text/markdown", + Cookie: setCookieHeader.join("; "), }, maxRedirects: 0, @@ -92,49 +94,119 @@ 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); + try { + //const username = "meta_user"; + //const password = "meta_password"; // Replace with a secure password or fetch from config + + // Register the user using the method described in the comment above + await axios.post( + "http://hedgedoc:3000/register", + "email=testctf2%40trsk.cc&password=ooooooooof", + { + 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", + "Sec-GPC": "1", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "iframe", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-User": "?1", + Priority: "u=4", + }, + withCredentials: true, + maxRedirects: 0, + validateStatus: (status: number) => + status === 200 || status === 409 || status === 302, // 409 if already exists + } + ); - let padPath: string; - if (padPathOrUrl.startsWith("/")) { - padPath = padPathOrUrl.slice(1); - } else { - padPath = new URL(padPathOrUrl).pathname.slice(1); - } + const loginResponse = await axios.post( + "http://hedgedoc:3000/login", + "email=testctf2%40trsk.cc&password=ooooooooof", + { + 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", + "Sec-GPC": "1", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "iframe", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "same-origin", + "Sec-Fetch-User": "?1", + Priority: "u=4", + }, + withCredentials: true, + maxRedirects: 0, + validateStatus: (status: number) => + status === 200 || status === 302, + } + ); - const padUrl = `${config.pad.showUrl}${padPath}`; + const setCookieHeader = loginResponse.headers["set-cookie"]; + console.log("Login Set-Cookie header:", setCookieHeader); - 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( + setCookieHeader ?? [], + 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); + return []; + } }, }, }, diff --git a/docker-compose.yml b/docker-compose.yml index 0624509f6..af8ec0217 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,7 @@ services: - ctfnote restart: unless-stopped environment: + META_USER_PASSWORD: lol PAD_CREATE_URL: http://hedgedoc:3000/new PAD_SHOW_URL: / DB_DATABASE: ctfnote @@ -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 From b5d9c8ec48d10b7bf9ffef026aa9d01c27dfa5f1 Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:02:39 +0200 Subject: [PATCH 03/11] Added error handling --- api/src/config.ts | 4 + api/src/plugins/createTask.ts | 122 +++++++++--------- docker-compose.dev.yml | 1 + docker-compose.yml | 2 +- .../src/components/Dialogs/TaskEditDialog.vue | 17 ++- 5 files changed, 78 insertions(+), 68 deletions(-) diff --git a/api/src/config.ts b/api/src/config.ts index c97f8dfad..4047feb4d 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; + metaUserName: string; + metaUserPassword: 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: getEnv("CMD_META_USER_USERNAME", "CTFNote_Bot"), //has to be an email address + metaUserPassword: getEnv("CMD_META_USER_PASSWORD", "") }, web: { port: getEnvInt("WEB_PORT"), diff --git a/api/src/plugins/createTask.ts b/api/src/plugins/createTask.ts index be6231743..a8e740d55 100644 --- a/api/src/plugins/createTask.ts +++ b/api/src/plugins/createTask.ts @@ -56,15 +56,64 @@ export async function createPad( return res.headers.location; } catch (e) { throw Error( - `Call to ${ - config.pad.createUrl - } during task creation failed. Length of note: ${ - buildNoteContent(title, description, tags).length + `Call to ${config.pad.createUrl + } during task creation failed. Length of note: ${buildNoteContent(title, description, tags).length }` ); } } +async function registerAndLoginUser(): Promise { + const username = config.pad.metaUserName; + const password = config.pad.metaUserPassword; + + 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}`); + } + +} + export default makeExtendSchemaPlugin((build) => { const { pgSql: sql } = build; return { @@ -95,64 +144,11 @@ export default makeExtendSchemaPlugin((build) => { resolveInfo ) => { try { - //const username = "meta_user"; - //const password = "meta_password"; // Replace with a secure password or fetch from config - - // Register the user using the method described in the comment above - await axios.post( - "http://hedgedoc:3000/register", - "email=testctf2%40trsk.cc&password=ooooooooof", - { - 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", - "Sec-GPC": "1", - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "iframe", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-User": "?1", - 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=testctf2%40trsk.cc&password=ooooooooof", - { - 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", - "Sec-GPC": "1", - "Upgrade-Insecure-Requests": "1", - "Sec-Fetch-Dest": "iframe", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "same-origin", - "Sec-Fetch-User": "?1", - Priority: "u=4", - }, - withCredentials: true, - maxRedirects: 0, - validateStatus: (status: number) => - status === 200 || status === 302, - } - ); - const setCookieHeader = loginResponse.headers["set-cookie"]; - console.log("Login Set-Cookie header:", setCookieHeader); + let cookie: string[] | undefined = undefined; + if (config.pad.metaUserPassword !== "") { + cookie = await registerAndLoginUser(); + } const { rows: [isAllowed], @@ -166,7 +162,7 @@ export default makeExtendSchemaPlugin((build) => { } const padPathOrUrl = await createPad( - setCookieHeader ?? [], + cookie ?? [], title, description, tags @@ -205,7 +201,7 @@ export default makeExtendSchemaPlugin((build) => { }); } catch (error) { console.error("error:", error); - return []; + throw new Error(`Creating task failed: ${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 af8ec0217..05221a39e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,7 @@ services: - ctfnote restart: unless-stopped environment: - META_USER_PASSWORD: lol + CMD_META_USER_PASSWORD: 742c37381007e2a26b126614db736f5e1 PAD_CREATE_URL: http://hedgedoc:3000/new PAD_SHOW_URL: / DB_DATABASE: ctfnote diff --git a/front/src/components/Dialogs/TaskEditDialog.vue b/front/src/components/Dialogs/TaskEditDialog.vue index 0c096aeb4..97eff91f1 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,12 +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({ message: `Task ${this.form.title} is being updated...`, From 584fb300e28614ced79b63a92b89d1181dbdbf5c Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:03:09 +0200 Subject: [PATCH 04/11] formatting --- api/src/config.ts | 2 +- api/src/plugins/createTask.ts | 17 ++++++++++------- front/src/components/Dialogs/TaskEditDialog.vue | 13 +++++++------ 3 files changed, 18 insertions(+), 14 deletions(-) diff --git a/api/src/config.ts b/api/src/config.ts index 4047feb4d..41d308e00 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -93,7 +93,7 @@ const config: CTFNoteConfig = { domain: getEnv("CMD_DOMAIN", ""), useSSL: getEnv("CMD_PROTOCOL_USESSL", "false"), metaUserName: getEnv("CMD_META_USER_USERNAME", "CTFNote_Bot"), //has to be an email address - metaUserPassword: getEnv("CMD_META_USER_PASSWORD", "") + metaUserPassword: getEnv("CMD_META_USER_PASSWORD", ""), }, web: { port: getEnvInt("WEB_PORT"), diff --git a/api/src/plugins/createTask.ts b/api/src/plugins/createTask.ts index a8e740d55..5c647418a 100644 --- a/api/src/plugins/createTask.ts +++ b/api/src/plugins/createTask.ts @@ -56,8 +56,10 @@ export async function createPad( return res.headers.location; } catch (e) { throw Error( - `Call to ${config.pad.createUrl - } during task creation failed. Length of note: ${buildNoteContent(title, description, tags).length + `Call to ${ + config.pad.createUrl + } during task creation failed. Length of note: ${ + buildNoteContent(title, description, tags).length }` ); } @@ -83,7 +85,8 @@ async function registerAndLoginUser(): Promise { }, withCredentials: true, maxRedirects: 0, - validateStatus: (status: number) => status === 200 || status === 409 || status === 302, // 409 if already exists + validateStatus: (status: number) => + status === 200 || status === 409 || status === 302, // 409 if already exists } ); @@ -107,11 +110,12 @@ async function registerAndLoginUser(): Promise { ); const setCookieHeader = loginResponse.headers["set-cookie"]; - return setCookieHeader ?? [] + return setCookieHeader ?? []; } catch (error) { - throw Error(`Login to hedgedoc during task creation failed. Error: ${error}`); + throw Error( + `Login to hedgedoc during task creation failed. Error: ${error}` + ); } - } export default makeExtendSchemaPlugin((build) => { @@ -144,7 +148,6 @@ export default makeExtendSchemaPlugin((build) => { resolveInfo ) => { try { - let cookie: string[] | undefined = undefined; if (config.pad.metaUserPassword !== "") { cookie = await registerAndLoginUser(); diff --git a/front/src/components/Dialogs/TaskEditDialog.vue b/front/src/components/Dialogs/TaskEditDialog.vue index 97eff91f1..ad27c9733 100644 --- a/front/src/components/Dialogs/TaskEditDialog.vue +++ b/front/src/components/Dialogs/TaskEditDialog.vue @@ -138,19 +138,20 @@ export default defineComponent({ message: `Task ${this.form.title} is being created...`, }); 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 ?? ''}) `); + 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)); } - } catch(e) { - console.error("error creating task:", e) + } catch (e) { + console.error('error creating task:', e); } - } else if (this.task) { this.notifySuccess({ message: `Task ${this.form.title} is being updated...`, From ad76474d3dfcbfdde0b0dd4a16609553fcf0122f Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:08:56 +0200 Subject: [PATCH 05/11] forbid registering hedgedoc users from outside --- front/nginx.conf | 5 +++++ 1 file changed, 5 insertions(+) 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; From be72730a2e505c71ceeefc905ff4b6ad54fced56 Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:13:00 +0200 Subject: [PATCH 06/11] username is email again --- api/src/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api/src/config.ts b/api/src/config.ts index 41d308e00..87d05be87 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -92,8 +92,8 @@ const config: CTFNoteConfig = { documentMaxLength: Number(getEnv("CMD_DOCUMENT_MAX_LENGTH", "100000")), domain: getEnv("CMD_DOMAIN", ""), useSSL: getEnv("CMD_PROTOCOL_USESSL", "false"), - metaUserName: getEnv("CMD_META_USER_USERNAME", "CTFNote_Bot"), //has to be an email address - metaUserPassword: getEnv("CMD_META_USER_PASSWORD", ""), + metaUserName: getEnv("CMD_META_USER_USERNAME", "CTFNote_Bot@ctf0.de"), //has to be an email address + metaUserPassword: getEnv("CMD_META_USER_PASSWORD", "") }, web: { port: getEnvInt("WEB_PORT"), From bb903e425ead9a3318a9cf7d09fa08e6f67d0d37 Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:13:11 +0200 Subject: [PATCH 07/11] hedgedoc username is email again --- api/src/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/src/config.ts b/api/src/config.ts index 87d05be87..faad7b89e 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -93,7 +93,7 @@ const config: CTFNoteConfig = { domain: getEnv("CMD_DOMAIN", ""), useSSL: getEnv("CMD_PROTOCOL_USESSL", "false"), metaUserName: getEnv("CMD_META_USER_USERNAME", "CTFNote_Bot@ctf0.de"), //has to be an email address - metaUserPassword: getEnv("CMD_META_USER_PASSWORD", "") + metaUserPassword: getEnv("CMD_META_USER_PASSWORD", ""), }, web: { port: getEnvInt("WEB_PORT"), From 552756a9c8ce1d2980b8bbcf6365f2e77afa89f5 Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:31:50 +0200 Subject: [PATCH 08/11] implemented user login for discord plugin --- api/src/discord/utils/messages.ts | 10 ++++-- api/src/plugins/createTask.ts | 54 +----------------------------- api/src/utils/hedgedoc.ts | 55 +++++++++++++++++++++++++++++++ 3 files changed, 63 insertions(+), 56 deletions(-) create mode 100644 api/src/utils/hedgedoc.ts diff --git a/api/src/discord/utils/messages.ts b/api/src/discord/utils/messages.ts index 540a7e1fb..35245b2e1 100644 --- a/api/src/discord/utils/messages.ts +++ b/api/src/discord/utils/messages.ts @@ -14,6 +14,8 @@ 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 +247,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,7 +256,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") ); @@ -273,7 +277,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") ); @@ -288,7 +292,7 @@ export async function createPadWithoutLimit( } return await createPad( - [], + cookie, `${ctfTitle} ${discordArchiveTaskName}`, firstPadContent ); diff --git a/api/src/plugins/createTask.ts b/api/src/plugins/createTask.ts index 5c647418a..303aec30b 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, @@ -65,59 +66,6 @@ export async function createPad( } } -async function registerAndLoginUser(): Promise { - const username = config.pad.metaUserName; - const password = config.pad.metaUserPassword; - - 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}` - ); - } -} - export default makeExtendSchemaPlugin((build) => { const { pgSql: sql } = build; return { diff --git a/api/src/utils/hedgedoc.ts b/api/src/utils/hedgedoc.ts new file mode 100644 index 000000000..ccb657e00 --- /dev/null +++ b/api/src/utils/hedgedoc.ts @@ -0,0 +1,55 @@ +import axios from "axios"; +import config from "../config"; + +export async function registerAndLoginUser(): Promise { + const username = config.pad.metaUserName; + const password = config.pad.metaUserPassword; + + 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}` + ); + } +} \ No newline at end of file From 96fe448424233c6e442970a3d5c022cd1c2608bb Mon Sep 17 00:00:00 2001 From: Philipp Date: Fri, 10 Oct 2025 11:32:03 +0200 Subject: [PATCH 09/11] formatting --- api/src/discord/utils/messages.ts | 3 +-- api/src/utils/hedgedoc.ts | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/api/src/discord/utils/messages.ts b/api/src/discord/utils/messages.ts index 35245b2e1..fae9bcebe 100644 --- a/api/src/discord/utils/messages.ts +++ b/api/src/discord/utils/messages.ts @@ -16,7 +16,6 @@ import { challengesTalkChannelName, getTaskChannel } from "../agile/channels"; import { createPad } from "../../plugins/createTask"; import { registerAndLoginUser } from "../..//utils/hedgedoc"; - export const discordArchiveTaskName = "Discord archive"; export async function sendMessageToChannel( @@ -248,7 +247,7 @@ export async function createPadWithoutLimit( let padIndex = 1; const cookie = await registerAndLoginUser(); - + for (const message of messages) { const messageLength = message.length; diff --git a/api/src/utils/hedgedoc.ts b/api/src/utils/hedgedoc.ts index ccb657e00..548c0c82d 100644 --- a/api/src/utils/hedgedoc.ts +++ b/api/src/utils/hedgedoc.ts @@ -52,4 +52,4 @@ export async function registerAndLoginUser(): Promise { `Login to hedgedoc during task creation failed. Error: ${error}` ); } -} \ No newline at end of file +} From 8430719ee3e1656ce3d093a01a38358f7a983390 Mon Sep 17 00:00:00 2001 From: Philipp Date: Sat, 11 Oct 2025 21:53:09 +0200 Subject: [PATCH 10/11] autogenerate password and save it inside database --- api/src/config.ts | 6 ++--- api/src/index.ts | 5 ++++ api/src/plugins/createTask.ts | 2 +- api/src/utils/hedgedoc.ts | 51 ++++++++++++++++++++++++++++++++++- docker-compose.yml | 2 +- 5 files changed, 60 insertions(+), 6 deletions(-) diff --git a/api/src/config.ts b/api/src/config.ts index faad7b89e..ac66693da 100644 --- a/api/src/config.ts +++ b/api/src/config.ts @@ -34,8 +34,8 @@ export type CTFNoteConfig = DeepReadOnly<{ documentMaxLength: number; domain: string; useSSL: string; + nonpublicPads: boolean; metaUserName: string; - metaUserPassword: string; }; web: { @@ -92,8 +92,8 @@ const config: CTFNoteConfig = { documentMaxLength: Number(getEnv("CMD_DOCUMENT_MAX_LENGTH", "100000")), domain: getEnv("CMD_DOMAIN", ""), useSSL: getEnv("CMD_PROTOCOL_USESSL", "false"), - metaUserName: getEnv("CMD_META_USER_USERNAME", "CTFNote_Bot@ctf0.de"), //has to be an email address - metaUserPassword: getEnv("CMD_META_USER_PASSWORD", ""), + 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/index.ts b/api/src/index.ts index de82a148b..5c537f5ae 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -23,6 +23,7 @@ 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; @@ -162,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 303aec30b..9b9f7e83a 100644 --- a/api/src/plugins/createTask.ts +++ b/api/src/plugins/createTask.ts @@ -97,7 +97,7 @@ export default makeExtendSchemaPlugin((build) => { ) => { try { let cookie: string[] | undefined = undefined; - if (config.pad.metaUserPassword !== "") { + if (config.pad.nonpublicPads) { cookie = await registerAndLoginUser(); } diff --git a/api/src/utils/hedgedoc.ts b/api/src/utils/hedgedoc.ts index 548c0c82d..68c35d470 100644 --- a/api/src/utils/hedgedoc.ts +++ b/api/src/utils/hedgedoc.ts @@ -1,9 +1,58 @@ 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 = config.pad.metaUserPassword; + const password = getPassword(); //TODO cache somehow try { await axios.post( diff --git a/docker-compose.yml b/docker-compose.yml index 05221a39e..47163f12b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,7 +8,6 @@ services: - ctfnote restart: unless-stopped environment: - CMD_META_USER_PASSWORD: 742c37381007e2a26b126614db736f5e1 PAD_CREATE_URL: http://hedgedoc:3000/new PAD_SHOW_URL: / DB_DATABASE: ctfnote @@ -29,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:-} From 6a9da0ec0b92806d611fba9661be0e954722609e Mon Sep 17 00:00:00 2001 From: Philipp Date: Sat, 11 Oct 2025 21:53:33 +0200 Subject: [PATCH 11/11] formatting --- api/src/utils/hedgedoc.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/api/src/utils/hedgedoc.ts b/api/src/utils/hedgedoc.ts index 68c35d470..c6062d0b7 100644 --- a/api/src/utils/hedgedoc.ts +++ b/api/src/utils/hedgedoc.ts @@ -21,17 +21,18 @@ export async function getPassword(): Promise { } 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(''); + .map(() => + String.fromCharCode(Math.floor(Math.random() * (126 - 33 + 1)) + 33) + ) + .join(""); try { const query = @@ -44,7 +45,7 @@ export async function initHedgedocPassword(): Promise { "Failed to set hedgedoc_nonpublic_pads flag in the database:", error ); - throw error + throw error; } finally { pgClient.release(); }