Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 35 additions & 12 deletions docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,16 +44,33 @@ relying on a filter to exclude private rows.
## Contact Form

* The contact form is a public, unauthenticated endpoint and is treated as an
abuse surface. Requirements:
* Server-side input validation on all fields.
* Rate limiting to prevent bulk submission abuse.
* Basic bot mitigation (e.g. a honeypot field or equivalent low-friction
measure) — full CAPTCHA is avoided if possible, given accessibility concerns
with CAPTCHA and this site's accessibility positioning; if a bot-mitigation
measure with accessibility implications is ever considered, it is a
stop-and-consult design decision, not an implementation-time default.
abuse surface. Implemented protections, layered:
* Server-side input validation on all fields (name, email format, topic
against an allowed list, minimum message length), independent of the
client-side validation in the page itself.
* A honeypot field (`website`), hidden from sighted and assistive-technology
users alike (`aria-hidden`, visually off-screen, not part of the tab
order). A populated honeypot is silently rejected without revealing that
detection occurred.
* Google reCAPTCHA v2 (checkbox variant, not the distorted-text challenge).
Chosen over reCAPTCHA v3 specifically because v3's behavioral scoring has
a documented history of penalizing atypical interaction patterns,
including keyboard-only and screen-reader-driven navigation — a real risk
given this site's audience. The checkbox variant can still occasionally
escalate to a secondary challenge for sessions Google's own risk engine
flags, which is outside this project's control; Google provides an audio
alternative for that case. This is a third-party script that sends
visitor behavioral data to Google — treated as the explicit third-party
tracking decision called for above, not a default.
* Rate limiting via a Cloudflare KV-backed counter, keyed by client IP
(`src/lib/contact/rateLimit.ts`), capped per time window.
* Submitted data is not publicly queryable and is not exposed through any public
API route.
API route. Messages are relayed via the Gmail API (OAuth2, not raw SMTP —
Cloudflare Workers does not reliably support raw SMTP), using credentials
supplied by the primary contributor, stored as Cloudflare Worker secrets.
* All four contact-form logic modules (validation, reCAPTCHA verification,
rate limiting, Gmail send) are pure/testable and have unit test coverage,
per the CI policy in `PROJECT.md`.

## Third-Party Services

Expand All @@ -71,6 +88,12 @@ relying on a filter to exclude private rows.

## Open Items

* Specific email delivery service selection for the contact form (not yet decided).
* Specific rate-limiting implementation (Cloudflare-native rate limiting vs.
application-level) — to be decided when the contact form is designed.
* The contact form's KV namespace (`RATE_LIMIT`), Gmail API OAuth2
credentials, and reCAPTCHA site/secret key pair are not yet provisioned.
All three require manual setup outside this repository (Cloudflare KV
namespace creation; a Google Cloud project with Gmail API enabled and an
OAuth consent flow run once to obtain a refresh token; reCAPTCHA site
registration in Google's admin console) before the contact form is
functional in production. The code is written against these as named
bindings/secrets (see `wrangler.toml`, `src/env.d.ts`) and will fail
clearly, not silently, if they are unset.
8 changes: 8 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
},
"devDependencies": {
"@axe-core/playwright": "^4.9.0",
"@cloudflare/workers-types": "^5.20260809.1",
"@eslint/js": "^9.0.0",
"@playwright/test": "^1.45.0",
"@vitest/coverage-v8": "^3.2.7",
Expand Down
13 changes: 13 additions & 0 deletions src/env.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/// <reference types="astro/client" />
/// <reference types="@cloudflare/workers-types" />

declare namespace Cloudflare {
interface Env {
RATE_LIMIT: KVNamespace;
GOOGLE_CLIENT_ID: string;
GOOGLE_CLIENT_SECRET: string;
GOOGLE_REFRESH_TOKEN: string;
GMAIL_SENDER: string;
RECAPTCHA_SECRET: string;
}
}
70 changes: 70 additions & 0 deletions src/layouts/BaseLayout.astro
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,36 @@ const { title } = Astro.props;
</head>
<body>
<a href="#main-content" class="skip-link">Skip to main content</a>
<header>
<nav aria-label="Main" class="main-nav">
<ul>
<li><a href="/">Home</a></li>
<li><a href="/contact">Contact</a></li>
</ul>
</nav>
</header>
<slot />
<footer class="site-footer">
<nav aria-label="Social profiles">
<ul>
<li>
<a href="https://www.linkedin.com/in/JadWauthier" target="_blank" rel="noopener noreferrer"
>LinkedIn<span class="visually-hidden"> (opens in a new tab)</span></a
>
</li>
<li>
<a href="https://x.com/jtwauthier" target="_blank" rel="noopener noreferrer"
>X<span class="visually-hidden"> (opens in a new tab)</span></a
>
</li>
<li>
<a href="https://github.com/blindtechmage" target="_blank" rel="noopener noreferrer"
>GitHub<span class="visually-hidden"> (opens in a new tab)</span></a
>
</li>
</ul>
</nav>
</footer>
</body>
</html>

Expand All @@ -32,4 +61,45 @@ const { title } = Astro.props;
color: #fff;
padding: 0.5em 1em;
}
.main-nav ul {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 0.5rem;
}
.main-nav a {
display: inline-block;
min-width: 24px;
min-height: 24px;
padding: 0.75rem 1rem;
}
.site-footer {
margin-block-start: 3rem;
border-top: 1px solid currentColor;
}
.site-footer ul {
display: flex;
list-style: none;
margin: 0;
padding: 0;
gap: 0.5rem;
}
.site-footer a {
display: inline-block;
min-width: 24px;
min-height: 24px;
padding: 0.75rem 1rem;
}
.visually-hidden {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
</style>
90 changes: 90 additions & 0 deletions src/lib/contact/gmail.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
const TOKEN_URL = 'https://oauth2.googleapis.com/token';
const SEND_URL = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send';

export interface GmailCredentials {
clientId: string;
clientSecret: string;
refreshToken: string;
sender: string;
}

export interface ContactMessage {
name: string;
email: string;
topic: string;
message: string;
}

function base64UrlEncode(input: string): string {
const bytes = new TextEncoder().encode(input);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

export function buildRawMessage(credentials: GmailCredentials, contact: ContactMessage): string {
const lines = [
`From: ${credentials.sender}`,
`To: ${credentials.sender}`,
`Reply-To: ${contact.email}`,
`Subject: BlindTechMage contact form: ${contact.topic}`,
'Content-Type: text/plain; charset=utf-8',
'',
`Name: ${contact.name}`,
`Email: ${contact.email}`,
`Topic: ${contact.topic}`,
'',
contact.message,
];
return base64UrlEncode(lines.join('\r\n'));
}

async function getAccessToken(
credentials: GmailCredentials,
fetchImpl: typeof fetch
): Promise<string> {
const response = await fetchImpl(TOKEN_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: credentials.clientId,
client_secret: credentials.clientSecret,
refresh_token: credentials.refreshToken,
grant_type: 'refresh_token',
}),
});

if (!response.ok) {
throw new Error(`Failed to obtain Gmail access token: ${response.status}`);
}

const data = (await response.json()) as { access_token?: string };
if (!data.access_token) {
throw new Error('Gmail token response did not include an access token.');
}
return data.access_token;
}

export async function sendContactEmail(
credentials: GmailCredentials,
contact: ContactMessage,
fetchImpl: typeof fetch = fetch
): Promise<void> {
const accessToken = await getAccessToken(credentials, fetchImpl);
const raw = buildRawMessage(credentials, contact);

const response = await fetchImpl(SEND_URL, {
method: 'POST',
headers: {
authorization: `Bearer ${accessToken}`,
'content-type': 'application/json',
},
body: JSON.stringify({ raw }),
});

if (!response.ok) {
throw new Error(`Failed to send contact email: ${response.status}`);
}
}
20 changes: 20 additions & 0 deletions src/lib/contact/rateLimit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export const RATE_LIMIT_WINDOW_SECONDS = 60 * 60;
export const RATE_LIMIT_MAX_SUBMISSIONS = 5;

export interface RateLimitKV {
get(key: string): Promise<string | null>;
put(key: string, value: string, options?: { expirationTtl?: number }): Promise<void>;
}

export async function checkRateLimit(kv: RateLimitKV, identifier: string): Promise<boolean> {
const key = `contact-form:${identifier}`;
const current = await kv.get(key);
const count = current ? Number.parseInt(current, 10) : 0;

if (count >= RATE_LIMIT_MAX_SUBMISSIONS) {
return false;
}

await kv.put(key, String(count + 1), { expirationTtl: RATE_LIMIT_WINDOW_SECONDS });
return true;
}
34 changes: 34 additions & 0 deletions src/lib/contact/recaptcha.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
const VERIFY_URL = 'https://www.google.com/recaptcha/api/siteverify';

export interface RecaptchaVerifyResult {
success: boolean;
}

export async function verifyRecaptcha(
token: string,
secret: string,
remoteIp: string | undefined,
fetchImpl: typeof fetch = fetch
): Promise<RecaptchaVerifyResult> {
if (!token) {
return { success: false };
}

const body = new URLSearchParams({ secret, response: token });
if (remoteIp) {
body.set('remoteip', remoteIp);
}

const response = await fetchImpl(VERIFY_URL, {
method: 'POST',
headers: { 'content-type': 'application/x-www-form-urlencoded' },
body,
});

if (!response.ok) {
return { success: false };
}

const data = (await response.json()) as { success?: boolean };
return { success: data.success === true };
}
59 changes: 59 additions & 0 deletions src/lib/contact/validation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
export const CONTACT_TOPICS = [
'general',
'consulting',
'collaboration',
'speaking',
'other',
] as const;

export type ContactTopic = (typeof CONTACT_TOPICS)[number];

export const MIN_MESSAGE_LENGTH = 20;

export interface ContactFormInput {
name: string;
email: string;
topic: string;
message: string;
}

export interface ContactFormErrors {
name?: string;
email?: string;
topic?: string;
message?: string;
}

export interface ContactFormValidationResult {
valid: boolean;
errors: ContactFormErrors;
}

const EMAIL_PATTERN = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export function validateContactForm(input: ContactFormInput): ContactFormValidationResult {
const errors: ContactFormErrors = {};

if (!input.name.trim()) {
errors.name = 'Please enter your name.';
}

if (!input.email.trim()) {
errors.email = 'Please enter your email address.';
} else if (!EMAIL_PATTERN.test(input.email.trim())) {
errors.email = 'Please enter a valid email address.';
}

if (!CONTACT_TOPICS.includes(input.topic as ContactTopic)) {
errors.topic = 'Please choose a topic.';
}

if (input.message.trim().length < MIN_MESSAGE_LENGTH) {
errors.message = `Please enter at least ${MIN_MESSAGE_LENGTH} characters.`;
}

return {
valid: Object.keys(errors).length === 0,
errors,
};
}
Loading
Loading