This commit is contained in:
Felix Klement committed 2026-10-05 10:44:55 +02:00
1 parent 490444032c
commit efecc500c4
15 files changed
+341 -39

No files matched your search

+4
View File
@@ -5,3 +5,7 @@ SMTP_USER=website@ciphercell.de
SMTP_PASS= SMTP_PASS=
CONTACT_FROM=website@ciphercell.de CONTACT_FROM=website@ciphercell.de
CONTACT_TO=office@ciphercell.de CONTACT_TO=office@ciphercell.de
# Signing secrets for the form's proof-of-work challenge (ALTCHA). Optional: without them each
# start makes its own. Generate with: openssl rand -hex 32
ALTCHA_HMAC_SECRET=
ALTCHA_HMAC_KEY_SECRET=
+16 -8
View File
@@ -61,13 +61,20 @@ Environment; see *Contact form* below.
- **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a - **Contact form.** The form posts `first_name`, `last_name`, `email`, `message`, `lang` and a
honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs` honeypot field `website` to `POST /contact`. That one path is handled by `server/contact.mjs`
(Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as (Node, nodemailer), which sends the message by SMTP to `CONTACT_TO` with the sender as
Reply-To. With scripting the form sends in place and shows the result under the button; Reply-To. The form sends in place and shows the result in a toast; a post that asks for HTML
without, the handler redirects to `/contact/sent` or `/contact/error` (under `/de` for German). is redirected to `/contact/sent` or `/contact/error` (under `/de` for German). Both pages are
Both pages are `noindex` and left out of the sitemap. The handler caps size and field lengths, `noindex` and left out of the sitemap. The handler caps size and field lengths, refuses
refuses cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour cross-site posts, and allows 5 messages per address per 10 minutes and 60 per hour overall.
overall.
In the container, nginx proxies `/contact` to the handler on `127.0.0.1:3000`; both are started Against spam, every message carries the answer to a proof-of-work challenge (ALTCHA,
`altcha-lib`). The form fetches a signed challenge from `GET /contact/challenge` when the
visitor first focuses it and solves it in the background (about half a second on a laptop, one
to two on a phone). The handler refuses a post without a valid, unexpired, unused solution, or
one sent less than 3 seconds after the challenge was issued. Nothing is stored in the browser
and no third party is involved, so there is nothing to consent to. The form therefore needs
JavaScript; without it a `<noscript>` line points to the email address.
In the container, nginx proxies `/contact` and `/contact/challenge` to the handler on `127.0.0.1:3000`; both are started
by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down, by `docker/entrypoint.sh`, which restarts the handler if it crashes. If the handler is down,
visitors are sent to the error page. Without SMTP settings the handler does not start and the visitors are sent to the error page. Without SMTP settings the handler does not start and the
rest of the site is served as usual. rest of the site is served as usual.
@@ -81,10 +88,11 @@ Environment; see *Contact form* below.
| `CONTACT_TO` | `office@ciphercell.de` | Where messages go. | | `CONTACT_TO` | `office@ciphercell.de` | Where messages go. |
| `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). | | `SMTP_SECURE` | by port | Force TLS from the start (`true`) or not (`false`). |
| `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. | | `SMTP_REQUIRE_TLS` | `true` | `false` only for a local mail catcher. |
| `ALTCHA_HMAC_SECRET`, `ALTCHA_HMAC_KEY_SECRET` | random per start | Signing secrets for the challenge (`openssl rand -hex 32`). Set them so a restart does not void challenges already handed out. |
In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see In Dokploy, set these under the application's Environment. Locally, put them in `.env` (see
`.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact` `.env.example`), run `npm run contact` next to `npm run dev`; the dev server proxies `/contact` and
to it. `/contact/challenge` to it.
- **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier` - **Alpha sign-up.** While `verifierComingSoon` in `src/data/flags.ts` is true, `/cipherverifier`
shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`, shows a coming-soon page whose form posts `email`, `consent=yes`, `lang`,
`list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is `list=cipherverifier-alpha` and the honeypot `website` to `POST /notify`. The handler is
+1 -1
View File
@@ -13,5 +13,5 @@ export default defineConfig({
}, },
build: { inlineStylesheets: 'auto' }, build: { inlineStylesheets: 'auto' },
// In development the form posts to the contact handler started with `npm run contact`. // In development the form posts to the contact handler started with `npm run contact`.
vite: { server: { proxy: { '^/contact$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } }, vite: { server: { proxy: { '^/contact(/challenge)?$': { target: 'http://127.0.0.1:3000', changeOrigin: false } } } },
}); });
+8
View File
@@ -43,6 +43,14 @@ server {
proxy_intercept_errors on; proxy_intercept_errors on;
error_page 502 503 504 = @contact_down; error_page 502 503 504 = @contact_down;
} }
# The proof-of-work challenge the form solves before it sends; issued by the same handler.
location = /contact/challenge {
limit_except GET { deny all; }
proxy_pass http://127.0.0.1:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 10s;
}
# Handler not running: send the visitor to the error page in their language. # Handler not running: send the visitor to the error page in their language.
location @contact_down { location @contact_down {
if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; } if ($http_referer ~ "/de(/|$|#)") { return 303 /de/contact/error; }
+119 -17
View File
@@ -9,6 +9,7 @@
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0", "@fontsource-variable/inter-tight": "^5.3.0",
"altcha-lib": "^2.6.0",
"astro": "^7.3.3", "astro": "^7.3.3",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
@@ -699,6 +700,7 @@
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==", "integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@emnapi/wasi-threads": "1.2.3", "@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0" "tslib": "^2.4.0"
@@ -710,6 +712,7 @@
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==", "integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
@@ -2095,6 +2098,7 @@
"integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==", "integrity": "sha512-TXyindR+lBr22aJIdMQzCFHPHR6cR4js838mRDCSz5hOKWZvZwsXSSiXDmjRj4iJmgl+sR9O+1mkoVBSMadNug==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~7.18.0" "undici-types": "~7.18.0"
} }
@@ -2232,6 +2236,7 @@
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -2268,6 +2273,75 @@
"ajv": "^8.0.0-beta.0" "ajv": "^8.0.0-beta.0"
} }
}, },
"node_modules/altcha-lib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
"license": "MIT",
"bin": {
"altcha-lib": "bin/cli.mjs"
},
"peerDependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.0 || ^9.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@sveltejs/kit": "^2.0.0",
"cookie-parser": "^1.0.0",
"cors": "^2.0.0",
"express": "^4.0.0 || ^5.0.0",
"fastify": "^5.0.0",
"h3": "^2.0.0",
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"@fastify/cookie": {
"optional": true
},
"@fastify/cors": {
"optional": true
},
"@fastify/formbody": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
"@nestjs/core": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
},
"@nestjs/platform-fastify": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"cookie-parser": {
"optional": true
},
"cors": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
},
"h3": {
"optional": true
},
"hono": {
"optional": true
}
}
},
"node_modules/am-i-vibing": { "node_modules/am-i-vibing": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz", "resolved": "https://registry.npmjs.org/am-i-vibing/-/am-i-vibing-0.4.0.tgz",
@@ -2864,6 +2938,7 @@
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"hasInstallScript": true, "hasInstallScript": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"esbuild": "bin/esbuild" "esbuild": "bin/esbuild"
}, },
@@ -3083,23 +3158,6 @@
"integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==", "integrity": "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==",
"license": "ISC" "license": "ISC"
}, },
"node_modules/h3": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
"license": "MIT",
"dependencies": {
"cookie-es": "^1.2.3",
"crossws": "^0.3.5",
"defu": "^6.1.6",
"destr": "^2.0.5",
"iron-webcrypto": "^1.2.1",
"node-mock-http": "^1.0.4",
"radix3": "^1.1.2",
"ufo": "^1.6.3",
"uncrypto": "^0.1.3"
}
},
"node_modules/hast-util-to-html": { "node_modules/hast-util-to-html": {
"version": "9.0.5", "version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz", "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
@@ -3912,6 +3970,7 @@
"integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==", "integrity": "sha512-WRFq3Wn3WId7LLROfMLdH7xaFr2jR62wU8nLO6rQUOLOxNZUviyJQs1M0iIhLexSFy+L+w0ch66wtoO2jRjG0A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -4067,6 +4126,16 @@
"@rolldown/binding-win32-x64-msvc": "1.2.9" "@rolldown/binding-win32-x64-msvc": "1.2.9"
} }
}, },
"node_modules/rou3": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-1.0.0.tgz",
"integrity": "sha512-ySeaQkSrua+ut9g+65I7FHqOFBaY8tV0FmccXItoq0q21se32Q3u4YiW7/RqXSosWgYJaaKwRHXy3BST9PeCNQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=20.19.0"
}
},
"node_modules/satteri": { "node_modules/satteri": {
"version": "0.10.5", "version": "0.10.5",
"resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz", "resolved": "https://registry.npmjs.org/satteri/-/satteri-0.10.5.tgz",
@@ -4236,6 +4305,19 @@
"url": "https://github.com/sponsors/wooorm" "url": "https://github.com/sponsors/wooorm"
} }
}, },
"node_modules/srvx": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/srvx/-/srvx-1.0.5.tgz",
"integrity": "sha512-KvSKRpgPG/oaq3cyT614OQ2bAa7DynuGamivCZeoZzIUULOdbQz6wUtnnVuC4+DCx2Zglzo8v5gBYmWIWYhDxA==",
"license": "MIT",
"optional": true,
"bin": {
"srvx": "bin/srvx.mjs"
},
"engines": {
"node": ">=20.16.0"
}
},
"node_modules/string-width": { "node_modules/string-width": {
"version": "8.2.2", "version": "8.2.2",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.2.tgz",
@@ -4388,6 +4470,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -4634,6 +4717,23 @@
} }
} }
}, },
"node_modules/unstorage/node_modules/h3": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/h3/-/h3-1.15.11.tgz",
"integrity": "sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==",
"license": "MIT",
"dependencies": {
"cookie-es": "^1.2.3",
"crossws": "^0.3.5",
"defu": "^6.1.6",
"destr": "^2.0.5",
"iron-webcrypto": "^1.2.1",
"node-mock-http": "^1.0.4",
"radix3": "^1.1.2",
"ufo": "^1.6.3",
"uncrypto": "^0.1.3"
}
},
"node_modules/verkit": { "node_modules/verkit": {
"version": "0.4.0", "version": "0.4.0",
"resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.0.tgz", "resolved": "https://registry.npmjs.org/verkit/-/verkit-0.4.0.tgz",
@@ -4679,6 +4779,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.0.tgz",
"integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==", "integrity": "sha512-lhZBVvEHefgE+HQZC9O7EBJgCU/nVzFNl7vkS4RE0APtWLP02/8QVIkQtzBxPquh7lq5/78NHipTj7ODQ6XuyQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.33.0", "lightningcss": "^1.33.0",
"picomatch": "^4.0.7", "picomatch": "^4.0.7",
@@ -5111,6 +5212,7 @@
"integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==", "integrity": "sha512-3NxN8+78OdzbT7C/WjGsyfPAtJaN3FNDsWxv7Y7mcDsT/oOmgW8BpyQQFFBnvZE3j9Y2Sdz1ULFLezL7Eb2yFw==",
"devOptional": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"peer": true,
"bin": { "bin": {
"yaml": "bin.mjs" "yaml": "bin.mjs"
}, },
+1
View File
@@ -13,6 +13,7 @@
}, },
"dependencies": { "dependencies": {
"@fontsource-variable/inter-tight": "^5.3.0", "@fontsource-variable/inter-tight": "^5.3.0",
"altcha-lib": "^2.6.0",
"astro": "^7.3.3", "astro": "^7.3.3",
"sharp": "^0.35.4" "sharp": "^0.35.4"
}, },
Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

-1
View File
@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" fill="#E9E5DF"/><text x="16" y="22" text-anchor="middle" font-family="Inter Tight, Inter, system-ui, sans-serif" font-weight="600" font-size="19" letter-spacing="-1" fill="#1A1A18">CC</text></svg>

Before

Width:  |  Height:  |  Size: 286 B

+82 -4
View File
@@ -1,14 +1,20 @@
/** /**
* The contact form handler. nginx proxies POST /contact here; everything else on the site stays static. * The contact form handler. nginx proxies POST /contact here; everything else on the site stays static.
* *
* The form posts first_name, last_name, email, message, lang and the honeypot website. A browser * The form posts first_name, last_name, email, message, lang, the honeypot website and altcha, the
* with scripting asks for JSON and shows the result in place; without scripting the answer is a * answer to a proof-of-work challenge from GET /contact/challenge (ALTCHA, solved in the browser
* redirect to /contact/sent or /contact/error (under /de for German). * while the visitor types). The browser asks for JSON and shows the result in place; a post that
* asks for HTML is answered with a redirect to /contact/sent or /contact/error (under /de for German).
* A post without a solved challenge is refused, so the form needs scripting; the address next
* to it does not.
* *
* Configuration comes from the environment only, see README.md. * Configuration comes from the environment only, see README.md.
*/ */
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { randomBytes } from 'node:crypto';
import nodemailer from 'nodemailer'; import nodemailer from 'nodemailer';
import { createChallenge, randomInt, verifySolution } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/pbkdf2';
const env = process.env; const env = process.env;
const PORT = Number(env.CONTACT_PORT || 3000); const PORT = Number(env.CONTACT_PORT || 3000);
@@ -22,6 +28,11 @@ if (!env.SMTP_HOST || !FROM) {
process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one. process.exit(78); // EX_CONFIG: the entrypoint does not restart on this one.
} }
/* Challenge secrets. Without them set, each start makes its own; challenges then only outlive a
restart if the visitor fetched a new one, which the form does after every send. */
const ALTCHA_SECRET = env.ALTCHA_HMAC_SECRET || randomBytes(32).toString('hex');
const ALTCHA_KEY_SECRET = env.ALTCHA_HMAC_KEY_SECRET || randomBytes(32).toString('hex');
const transport = nodemailer.createTransport({ const transport = nodemailer.createTransport({
host: env.SMTP_HOST, host: env.SMTP_HOST,
port: SMTP_PORT, port: SMTP_PORT,
@@ -38,6 +49,64 @@ const MAX = { first_name: 100, last_name: 100, email: 254, message: 5000 };
const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 }; const PER_IP = { count: 5, windowMs: 10 * 60 * 1000 };
const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 }; const GLOBAL = { count: 60, windowMs: 60 * 60 * 1000 };
/* Proof of work. About half a second of a laptop's time and one to two of a phone's, spent while
the visitor is still typing; a script pays it for every message. The challenge is signed, so
the server keeps nothing until a solution comes back. */
const POW = { algorithm: 'PBKDF2/SHA-256', cost: 1000, counterMin: 2000, counterMax: 5000 };
const CHALLENGE_TTL_MS = 30 * 60 * 1000;
// Nobody writes a message in three seconds from first touching the form; a script does.
const MIN_FILL_MS = 3000;
// Solved challenges, kept until they expire, so one solution sends one message.
const spent = new Map();
setInterval(() => {
const now = Date.now();
for (const [sig, expires] of spent) if (expires < now) spent.delete(sig);
}, 60 * 1000).unref();
function issueChallenge() {
const now = Date.now();
return createChallenge({
algorithm: POW.algorithm,
cost: POW.cost,
counter: randomInt(POW.counterMin, POW.counterMax),
deriveKey,
data: { issued: now },
expiresAt: new Date(now + CHALLENGE_TTL_MS),
hmacSignatureSecret: ALTCHA_SECRET,
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
});
}
/** The posted altcha field: base64 JSON of { challenge, solution }. */
async function solved(field, now = Date.now()) {
let payload;
try {
payload = JSON.parse(Buffer.from(field || '', 'base64').toString('utf8'));
} catch {
return false;
}
const { challenge, solution } = payload || {};
if (!challenge?.signature || spent.has(challenge.signature)) return false;
let result;
try {
result = await verifySolution({
challenge,
solution,
deriveKey,
hmacSignatureSecret: ALTCHA_SECRET,
hmacKeySignatureSecret: ALTCHA_KEY_SECRET,
});
} catch {
return false;
}
if (!result.verified) return false;
// Signed with the challenge, so it is the time the server issued it.
const issued = Number(challenge.parameters.data?.issued);
if (!(now - issued >= MIN_FILL_MS)) return false;
spent.set(challenge.signature, challenge.parameters.expiresAt * 1000);
return true;
}
const hits = new Map(); const hits = new Map();
const globalHits = []; const globalHits = [];
function limited(ip, now = Date.now()) { function limited(ip, now = Date.now()) {
@@ -85,7 +154,7 @@ function readBody(req) {
} }
function reply(req, res, lang, outcome) { function reply(req, res, lang, outcome) {
const status = { sent: 200, invalid: 400, limited: 429, failed: 502 }[outcome]; const status = { sent: 200, invalid: 400, limited: 429, unverified: 403, failed: 502 }[outcome];
if ((req.headers.accept || '').includes('application/json')) { if ((req.headers.accept || '').includes('application/json')) {
res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }); res.writeHead(status, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify({ ok: outcome === 'sent', outcome })); res.end(JSON.stringify({ ok: outcome === 'sent', outcome }));
@@ -99,6 +168,11 @@ function reply(req, res, lang, outcome) {
const server = createServer(async (req, res) => { const server = createServer(async (req, res) => {
const path = (req.url || '').split('?')[0]; const path = (req.url || '').split('?')[0];
if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; } if (path === '/contact/healthz') { res.writeHead(200).end('ok\n'); return; }
if (path === '/contact/challenge' && req.method === 'GET') {
res.writeHead(200, { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(await issueChallenge()));
return;
}
if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; } if (req.method !== 'POST' || path !== '/contact') { res.writeHead(404).end(); return; }
// A cross-site post is not a visitor using the form. // A cross-site post is not a visitor using the form.
@@ -121,6 +195,10 @@ const server = createServer(async (req, res) => {
const data = validate(form); const data = validate(form);
if (!data) { reply(req, res, lang, 'invalid'); return; } if (!data) { reply(req, res, lang, 'invalid'); return; }
// No solved challenge: a script, or a browser without scripting. Checked before the rate
// limits, so unsolved posts cannot use up the budget meant for visitors.
if (!(await solved(form.get('altcha')))) { reply(req, res, lang, 'unverified'); return; }
// nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs. // nginx sets X-Real-IP from the proxy chain; fall back to the socket for local runs.
const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown'; const ip = req.headers['x-real-ip'] || req.socket.remoteAddress || 'unknown';
if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; } if (limited(String(ip))) { reply(req, res, lang, 'limited'); return; }
+70
View File
@@ -6,12 +6,82 @@
"": { "": {
"name": "ciphercell-contact", "name": "ciphercell-contact",
"dependencies": { "dependencies": {
"altcha-lib": "^2.6.0",
"nodemailer": "^7.0.13" "nodemailer": "^7.0.13"
}, },
"engines": { "engines": {
"node": ">=20" "node": ">=20"
} }
}, },
"node_modules/altcha-lib": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/altcha-lib/-/altcha-lib-2.6.0.tgz",
"integrity": "sha512-mBp/KxMZmQkckWX5iM0gHqCm0qTLGhF9r8hhFjNrBpzP0qHZBadR9oafxVx9cTLHl4h2+f56N4hRIrZzvonYLQ==",
"license": "MIT",
"bin": {
"altcha-lib": "bin/cli.mjs"
},
"peerDependencies": {
"@fastify/cookie": "^11.0.0",
"@fastify/cors": "^11.0.0",
"@fastify/formbody": "^8.0.0 || ^9.0.0",
"@nestjs/common": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/core": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-express": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@nestjs/platform-fastify": "^10.0.0 || ^11.0.0 || ^12.0.0",
"@sveltejs/kit": "^2.0.0",
"cookie-parser": "^1.0.0",
"cors": "^2.0.0",
"express": "^4.0.0 || ^5.0.0",
"fastify": "^5.0.0",
"h3": "^2.0.0",
"hono": "^4.0.0"
},
"peerDependenciesMeta": {
"@fastify/cookie": {
"optional": true
},
"@fastify/cors": {
"optional": true
},
"@fastify/formbody": {
"optional": true
},
"@nestjs/common": {
"optional": true
},
"@nestjs/core": {
"optional": true
},
"@nestjs/platform-express": {
"optional": true
},
"@nestjs/platform-fastify": {
"optional": true
},
"@sveltejs/kit": {
"optional": true
},
"cookie-parser": {
"optional": true
},
"cors": {
"optional": true
},
"express": {
"optional": true
},
"fastify": {
"optional": true
},
"h3": {
"optional": true
},
"hono": {
"optional": true
}
}
},
"node_modules/nodemailer": { "node_modules/nodemailer": {
"version": "7.0.13", "version": "7.0.13",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz", "resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-7.0.13.tgz",
+1
View File
@@ -10,6 +10,7 @@
"node": ">=20" "node": ">=20"
}, },
"dependencies": { "dependencies": {
"altcha-lib": "^2.6.0",
"nodemailer": "^7.0.13" "nodemailer": "^7.0.13"
} }
} }
+31 -3
View File
@@ -51,6 +51,7 @@ const f = ui.contact.form;
<button class="button" type="submit">{f.submit}</button> <button class="button" type="submit">{f.submit}</button>
<p class="small muted"><span aria-hidden="true">* </span>{f.required}</p> <p class="small muted"><span aria-hidden="true">* </span>{f.required}</p>
</div> </div>
<noscript><p class="field--full small">{f.noscript}</p></noscript>
</form> </form>
</div> </div>
@@ -227,8 +228,13 @@ const f = ui.contact.form;
</style> </style>
<script> <script>
// With scripting the form sends in place and answers in a toast. Without it, the server // The form sends in place and answers in a toast. Every send carries the answer to a
// redirects to /contact/sent or /contact/error. // proof-of-work challenge (ALTCHA), worked out in the background from the moment the visitor
// first touches the form. Without scripting the server refuses the post and redirects to
// /contact/error, which points to the email address.
import { solveChallenge, type Challenge } from 'altcha-lib';
import { deriveKey } from 'altcha-lib/algorithms/web/pbkdf2';
const form = document.querySelector<HTMLFormElement>('[data-contact]'); const form = document.querySelector<HTMLFormElement>('[data-contact]');
const toast = document.querySelector<HTMLElement>('[data-toast]'); const toast = document.querySelector<HTMLElement>('[data-toast]');
if (form && toast) { if (form && toast) {
@@ -238,6 +244,22 @@ const f = ui.contact.form;
const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!; const close = toast.querySelector<HTMLButtonElement>('[data-toast-close]')!;
const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!; const submit = form.querySelector<HTMLButtonElement>('[type="submit"]')!;
// One challenge per message: fetched and solved once, spent by the send that carries it.
let proof: Promise<string | null> | null = null;
const solve = async (): Promise<string | null> => {
try {
const res = await fetch('/contact/challenge', { headers: { Accept: 'application/json' } });
if (!res.ok) return null;
const challenge: Challenge = await res.json();
const solution = await solveChallenge({ challenge, deriveKey });
return solution ? btoa(JSON.stringify({ challenge, solution })) : null;
} catch {
return null;
}
};
const prepare = () => (proof ??= solve());
form.addEventListener('focusin', prepare);
const show = (state: 'sending' | 'sent' | 'failed', text: string) => { const show = (state: 'sending' | 'sent' | 'failed', text: string) => {
toast.dataset.state = state; toast.dataset.state = state;
toast.setAttribute('data-open', ''); toast.setAttribute('data-open', '');
@@ -264,16 +286,22 @@ const f = ui.contact.form;
form.setAttribute('aria-busy', 'true'); form.setAttribute('aria-busy', 'true');
show('sending', ''); show('sending', '');
let outcome = 'failed'; let outcome = 'failed';
const altcha = await prepare();
proof = null;
if (altcha) {
const body = new URLSearchParams(new FormData(form) as unknown as Record<string, string>);
body.set('altcha', altcha);
try { try {
const res = await fetch(form.action, { const res = await fetch(form.action, {
method: 'POST', method: 'POST',
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
body: new URLSearchParams(new FormData(form) as unknown as Record<string, string>), body,
}); });
outcome = (await res.json()).outcome ?? 'failed'; outcome = (await res.json()).outcome ?? 'failed';
} catch { } catch {
outcome = 'failed'; outcome = 'failed';
} }
}
if (outcome === 'sent') { if (outcome === 'sent') {
form.reset(); form.reset();
show('sent', form.dataset.sent ?? ''); show('sent', form.dataset.sent ?? '');
+6 -4
View File
@@ -143,7 +143,7 @@ const en = {
}, },
contact: { contact: {
title: 'Talk to us.', title: 'Talk to us.',
body: 'Complex software, a tight compliance deadline, or a problem no one has solved yet. We respond within 24 hours.', body: 'Complex software, a tight deadline, or a problem no one has solved yet. We respond within 24 hours.',
address: ['Innstr. 43', '94032 Passau, Germany'], address: ['Innstr. 43', '94032 Passau, Germany'],
phone: '+49 (0)851 / 509-3218', phone: '+49 (0)851 / 509-3218',
phoneHref: 'tel:+498515093218', phoneHref: 'tel:+498515093218',
@@ -159,6 +159,7 @@ const en = {
sent: 'Message sent. We respond within 24 hours.', sent: 'Message sent. We respond within 24 hours.',
invalid: 'Please fill in every field and give a valid email address.', invalid: 'Please fill in every field and give a valid email address.',
failed: 'Your message did not go through. Please try again, or write to office@ciphercell.de.', failed: 'Your message did not go through. Please try again, or write to office@ciphercell.de.',
noscript: 'The form needs JavaScript. Without it, write to office@ciphercell.de.',
toast: { sending: 'Sending', sent: 'Sent', failed: 'Not sent', close: 'Close' }, toast: { sending: 'Sending', sent: 'Sent', failed: 'Not sent', close: 'Close' },
}, },
result: { result: {
@@ -309,7 +310,7 @@ const en = {
}, },
research: { research: {
title: ['Published', 'research.'], title: ['Published', 'research.'],
intro: 'Papers by our team on Open RAN, cloud-native infrastructure and hardware security. The method we sell grew out of this work.', intro: 'Expert perspectives on Open RAN security, cloud-native infrastructure, and hardware security, grounded in real-world engineering and backed by research.',
sortLabel: 'Sort', sortLabel: 'Sort',
sort: { cites: 'Most cited', newest: 'Newest', oldest: 'Oldest' }, sort: { cites: 'Most cited', newest: 'Newest', oldest: 'Oldest' },
filterLabel: 'Category', filterLabel: 'Category',
@@ -458,7 +459,7 @@ const de: typeof en = {
}, },
contact: { contact: {
title: 'Sprechen Sie mit uns.', title: 'Sprechen Sie mit uns.',
body: 'Komplexe Software, eine knappe Compliance-Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.', body: 'Komplexe Software, eine knappe Frist oder ein Problem, das noch niemand gelöst hat. Wir antworten innerhalb von 24 Stunden.',
address: ['Innstr. 43', '94032 Passau, Deutschland'], address: ['Innstr. 43', '94032 Passau, Deutschland'],
phone: '+49 (0)851 / 509-3218', phone: '+49 (0)851 / 509-3218',
phoneHref: 'tel:+498515093218', phoneHref: 'tel:+498515093218',
@@ -474,6 +475,7 @@ const de: typeof en = {
sent: 'Nachricht gesendet. Wir antworten innerhalb von 24 Stunden.', sent: 'Nachricht gesendet. Wir antworten innerhalb von 24 Stunden.',
invalid: 'Bitte füllen Sie alle Felder aus und geben Sie eine gültige E-Mail-Adresse an.', invalid: 'Bitte füllen Sie alle Felder aus und geben Sie eine gültige E-Mail-Adresse an.',
failed: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.', failed: 'Ihre Nachricht ist nicht angekommen. Bitte versuchen Sie es erneut oder schreiben Sie an office@ciphercell.de.',
noscript: 'Das Formular braucht JavaScript. Ohne schreiben Sie an office@ciphercell.de.',
toast: { sending: 'Wird gesendet', sent: 'Gesendet', failed: 'Nicht gesendet', close: 'Schließen' }, toast: { sending: 'Wird gesendet', sent: 'Gesendet', failed: 'Nicht gesendet', close: 'Schließen' },
}, },
result: { result: {
@@ -624,7 +626,7 @@ const de: typeof en = {
}, },
research: { research: {
title: ['Veröffentlichte', 'Forschung.'], title: ['Veröffentlichte', 'Forschung.'],
intro: 'Arbeiten unseres Teams zu Open RAN, cloud-nativer Infrastruktur und Hardware-Sicherheit. Die Methode, die wir anbieten, ist aus dieser Arbeit entstanden.', intro: 'Experteneinblicke in Open-RAN-Sicherheit, Cloud-native Infrastruktur und Hardware-Sicherheit, basierend auf praxiserprobter Ingenieursarbeit und wissenschaftlich fundiert.',
sortLabel: 'Sortieren', sortLabel: 'Sortieren',
sort: { cites: 'Meistzitiert', newest: 'Neueste', oldest: 'Älteste' }, sort: { cites: 'Meistzitiert', newest: 'Neueste', oldest: 'Älteste' },
filterLabel: 'Kategorie', filterLabel: 'Kategorie',
+2 -1
View File
@@ -40,7 +40,8 @@ const altDe = new URL(switchLang(Astro.url, 'de'), Astro.site);
<link rel="alternate" hreflang="en" href={altEn} /> <link rel="alternate" hreflang="en" href={altEn} />
<link rel="alternate" hreflang="de" href={altDe} /> <link rel="alternate" hreflang="de" href={altDe} />
<link rel="alternate" hreflang="x-default" href={altEn} /> <link rel="alternate" hreflang="x-default" href={altEn} />
<link rel="icon" href="/favicon.svg" type="image/svg+xml" /> <link rel="icon" href="/favicon.ico" sizes="any" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin /> <link rel="preload" href="/fonts/inter-tight-latin-wght-normal.woff2" as="font" type="font/woff2" crossorigin />
<meta property="og:type" content="website" /> <meta property="og:type" content="website" />
<meta property="og:site_name" content={ui.meta.siteName} /> <meta property="og:site_name" content={ui.meta.siteName} />