Skip to content

Commit bfb4483

Browse files
authored
Merge pull request #171 from reqcore-inc/fix/security-and-dependencies
feat: implement nonce-based CSP middleware for enhanced security
2 parents f6fe4ad + 921ea39 commit bfb4483

42 files changed

Lines changed: 1159 additions & 148 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

ARCHITECTURE.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ reqcore/
8080
│ ├── middleware/ # Global server middleware
8181
│ ├── plugins/
8282
│ │ ├── migrations.ts # Auto-apply migrations on startup
83+
│ │ ├── posthog.ts # PostHog server-side capture + filtered error hook
8384
│ │ └── s3-bucket.ts # Ensure S3 bucket exists + enforce private policy
8485
│ └── utils/ # Auto-imported server utilities
8586
│ ├── auth.ts # Better Auth instance
@@ -88,7 +89,8 @@ reqcore/
8889
│ ├── requireAuth.ts # Auth guard (throws 401/403)
8990
│ ├── s3.ts # S3/MinIO client, upload, delete, bucket policy
9091
│ ├── slugify.ts # URL slug generation for public job pages
91-
│ ├── rateLimit.ts # IP-based sliding window rate limiter
92+
│ ├── rateLimit.ts # IP-based sliding window rate limiter (in-memory, single-instance)
93+
│ ├── pgDumpEnv.ts # Allowlist of env vars passed to pg_dump (no secret leak)
9294
│ └── schemas/ # Shared Zod validation schemas
9395
│ ├── document.ts # MIME types, file limits, sanitizeFilename()
9496
│ ├── job.ts # Job create/update schemas
@@ -163,7 +165,7 @@ Nitro auto-imports everything from `server/utils/`. The core utilities are alway
163165
| `auth` | Better Auth instance |
164166
| `env` | Zod-validated environment variables |
165167
| `generateJobSlug` | URL slug generation for public job pages |
166-
| `createRateLimiter` | IP-based sliding window rate limiter |
168+
| `createRateLimiter` | IP-based sliding window rate limiter (in-memory; for multi-instance setups, terminate at the reverse proxy / CDN) |
167169
| `uploadToS3`, `deleteFromS3` | S3/MinIO file operations |
168170

169171
### 3. Environment Validation

SELF-HOSTING.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -531,11 +531,12 @@ Reqcore ships with security defaults that require no configuration:
531531
- **All services are localhost-bound** — PostgreSQL, MinIO, and Adminer are never exposed to the internet. Only the application port (3000) is accessible externally.
532532
- **Automatic CSRF protection** via Better Auth
533533
- **Encrypted OAuth tokens** with AES-256-GCM
534-
- **Rate limiting** on sensitive endpoints
534+
- **Rate limiting** on sensitive endpoints (in-memory, single-instance — see "Scaling horizontally" below if you run multiple replicas)
535535
- **Security headers** — `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` restricting camera/microphone/geolocation
536536
- **File upload validation** — MIME type verification, file size limits, filename sanitization
537537
- **Server-proxied downloads** — uploaded files are never served directly from storage; they pass through the application server, which enforces authentication and authorization
538538
- **Deny-by-default access control** — every API endpoint checks org membership and role permissions
539+
- **Backups never leak app secrets** — the in-app `pg_dump` runner spawns the child process with a minimal env (PGPASSWORD + a small whitelist of system vars) so application secrets like `BETTER_AUTH_SECRET`, `S3_SECRET_KEY`, and OAuth credentials are never inherited by the subprocess
539540

540541
### Additional Recommendations
541542

@@ -564,6 +565,27 @@ sudo apt install unattended-upgrades
564565
sudo dpkg-reconfigure -plow unattended-upgrades
565566
```
566567

568+
### Scaling horizontally
569+
570+
Reqcore is designed to run as a **single instance** on one VPS. The built-in
571+
rate limiter keeps state in memory, which is perfect for a single container
572+
but means two replicas would each enforce their own per-IP counters
573+
independently — a determined attacker could double their effective budget by
574+
spreading requests across replicas.
575+
576+
If you do need to run multiple Reqcore instances behind a load balancer,
577+
**move rate limiting to the edge** rather than into the app:
578+
579+
| Edge layer | What to configure |
580+
|------------|-------------------|
581+
| Cloudflare (free) | Security → WAF → Rate limiting rules per path |
582+
| Caddy | `rate_limit` directive in your Caddyfile |
583+
| Nginx | `limit_req_zone` + `limit_req` directives |
584+
585+
This is also more efficient — abusive traffic is dropped before it ever
586+
reaches a Nuxt process — and it removes the need for any extra infrastructure
587+
inside Reqcore itself.
588+
567589
---
568590

569591
## OIDC Single Sign-On (SSO)

app/app.vue

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,22 @@ useHead(() => ({
99
meta: i18nHead.value.meta,
1010
}))
1111
12+
// Blocking inline script to apply dark mode before first paint (prevents white
13+
// flash). The nonce attribute is required by the nonce-based CSP set in
14+
// server/middleware/csp.ts — without it the script would be blocked by the
15+
// Content Security Policy (CSP).
16+
const _nonce = import.meta.server ? (useRequestEvent()?.context?.nonce ?? '') : ''
17+
useHead({
18+
script: [
19+
{
20+
key: 'dark-mode-init',
21+
innerHTML: '(function(){try{var s=localStorage.getItem("reqcore-color-mode");var m=s||(window.matchMedia("(prefers-color-scheme:dark)").matches?"dark":"light");document.documentElement.classList.toggle("dark",m==="dark");document.documentElement.style.colorScheme=m}catch(e){}})()',
22+
tagPosition: 'head',
23+
...(_nonce ? { nonce: _nonce } : {}),
24+
},
25+
],
26+
})
27+
1228
// Sync Better Auth session → PostHog identity & org group
1329
await usePostHogIdentity()
1430
</script>

app/assets/css/main.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,9 +237,15 @@
237237

238238
/* ── Bento card subtle border-glow (Supabase-style) ───── */
239239
.bento-card {
240+
background: oklch(98.5% 0.002 264);
241+
border: 1px solid oklch(93.0% 0.006 264);
242+
}
243+
244+
.dark .bento-card {
240245
background:
241246
linear-gradient(180deg, rgba(255,255,255,0.02) 0%, rgba(255,255,255,0) 100%),
242247
#0c0c0f;
248+
border: none;
243249
}
244250

245251
/* ── Tech stack cards (Linear-style premium) ──────────── */
@@ -289,6 +295,10 @@
289295
}
290296

291297
.bento-card::before {
298+
content: none; /* hidden in light mode */
299+
}
300+
301+
.dark .bento-card::before {
292302
content: '';
293303
position: absolute;
294304
inset: 0;
@@ -309,6 +319,11 @@
309319
}
310320

311321
.bento-card:hover {
322+
background: oklch(96.5% 0.004 264);
323+
border-color: oklch(87.0% 0.008 264);
324+
}
325+
326+
.dark .bento-card:hover {
312327
background:
313328
linear-gradient(180deg, rgba(255,255,255,0.035) 0%, rgba(255,255,255,0.005) 100%),
314329
#0c0c0f;

app/components/PublicNavBar.vue

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script setup lang="ts">
2-
import { Github } from 'lucide-vue-next'
2+
import { Github, Sun, Moon } from 'lucide-vue-next'
33
44
defineProps<{
55
activePage?: 'features' | 'jobs' | 'roadmap' | 'blog' | 'docs'
@@ -8,15 +8,16 @@ defineProps<{
88
const { t } = useI18n()
99
const localePath = useLocalePath()
1010
const { data: session } = await authClient.useSession(useFetch)
11+
const { isDark, toggle: toggleColorMode } = useColorMode()
1112
</script>
1213

1314
<template>
14-
<nav class="fixed inset-x-0 top-0 z-50 border-b border-white/[0.06] bg-[#09090b]/80 backdrop-blur-xl">
15+
<nav class="fixed inset-x-0 top-0 z-50 border-b border-surface-200/80 dark:border-white/[0.06] bg-white/80 dark:bg-[#09090b]/80 backdrop-blur-xl">
1516
<div class="mx-auto flex h-14 max-w-6xl items-center justify-between px-6">
1617
<!-- Logo — links to marketing site (reqcore.com) -->
1718
<a
1819
:href="useRuntimeConfig().public.marketingUrl"
19-
class="flex items-center gap-2.5 text-[15px] font-semibold tracking-tight text-white"
20+
class="flex items-center gap-2.5 text-[15px] font-semibold tracking-tight text-surface-900 dark:text-white"
2021
>
2122
<img
2223
src="/eagle-mascot-logo-128.png"
@@ -35,15 +36,15 @@ const { data: session } = await authClient.useSession(useFetch)
3536
<NuxtLink
3637
:to="localePath('/jobs')"
3738
class="rounded-md px-3 py-1.5 text-[13px] font-medium transition"
38-
:class="activePage === 'jobs' ? 'text-white' : 'text-surface-400 hover:text-white'"
39+
:class="activePage === 'jobs' ? 'text-surface-900 dark:text-white' : 'text-surface-500 dark:text-surface-400 hover:text-surface-900 dark:hover:text-white'"
3940
>
4041
{{ t('home.nav.openPositions') }}
4142
</NuxtLink>
4243
<a
4344
href="https://github.com/reqcore-inc/reqcore"
4445
target="_blank"
4546
rel="noopener noreferrer"
46-
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-400 transition hover:text-white"
47+
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition hover:text-surface-900 dark:hover:text-white"
4748
>
4849
<Github class="h-3.5 w-3.5" />
4950
{{ t('home.nav.github') }}
@@ -52,25 +53,38 @@ const { data: session } = await authClient.useSession(useFetch)
5253

5354
<!-- Right: session actions + language switcher -->
5455
<div class="flex items-center gap-2">
56+
<ClientOnly>
57+
<button
58+
class="inline-flex items-center justify-center size-8 rounded-lg text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-white hover:bg-surface-100 dark:hover:bg-white/10 transition-all duration-200 cursor-pointer border-0 bg-transparent"
59+
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
60+
@click="toggleColorMode"
61+
>
62+
<Sun v-if="isDark" class="size-4" />
63+
<Moon v-else class="size-4" />
64+
</button>
65+
<template #fallback>
66+
<div class="size-8" aria-hidden="true" />
67+
</template>
68+
</ClientOnly>
5569
<LanguageSwitcher />
5670
<template v-if="session?.user">
5771
<NuxtLink
5872
:to="localePath('/dashboard')"
59-
class="rounded-md bg-white px-3.5 py-1.5 text-[13px] font-semibold text-[#09090b] transition hover:bg-white/90"
73+
class="rounded-md bg-surface-900 dark:bg-white px-3.5 py-1.5 text-[13px] font-semibold text-white dark:text-[#09090b] transition hover:bg-surface-800 dark:hover:bg-white/90"
6074
>
6175
{{ t('home.nav.dashboard') }}
6276
</NuxtLink>
6377
</template>
6478
<template v-else>
6579
<NuxtLink
6680
:to="localePath('/auth/sign-in')"
67-
class="hidden rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-400 transition hover:text-white sm:inline-flex"
81+
class="hidden rounded-md px-3 py-1.5 text-[13px] font-medium text-surface-500 dark:text-surface-400 transition hover:text-surface-900 dark:hover:text-white sm:inline-flex"
6882
>
6983
{{ t('home.nav.logIn') }}
7084
</NuxtLink>
7185
<NuxtLink
7286
:to="localePath('/auth/sign-up')"
73-
class="rounded-md bg-white px-3.5 py-1.5 text-[13px] font-semibold text-[#09090b] transition hover:bg-white/90"
87+
class="rounded-md bg-surface-900 dark:bg-white px-3.5 py-1.5 text-[13px] font-semibold text-white dark:text-[#09090b] transition hover:bg-surface-800 dark:hover:bg-white/90"
7488
>
7589
{{ t('home.nav.signUp') }}
7690
</NuxtLink>

app/composables/useColorMode.ts

Lines changed: 35 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,43 @@
33
*
44
* - Persists preference to `localStorage` under the key `reqcore-color-mode`.
55
* - Defaults to OS preference (`prefers-color-scheme: dark`) on first visit.
6-
* - Toggles the `.dark` class on `<html>` for Tailwind's dark variant.
6+
* - Manages the `.dark` class on `<html>` via both Nuxt's useHead (so it
7+
* survives Unhead's reactive attribute patching) and direct DOM manipulation
8+
* for immediate visual feedback.
79
*
810
* Must be called in `<script setup>` context.
911
*/
1012
export function useColorMode() {
1113
const colorMode = useState<'light' | 'dark'>('color-mode', () => 'light')
1214
const isDark = computed(() => colorMode.value === 'dark')
1315

16+
// Route the class through Nuxt's head management (Unhead) so that
17+
// reactive htmlAttrs updates (e.g. locale switches) do not inadvertently
18+
// strip the .dark class that was set by direct DOM manipulation.
19+
// The object syntax { dark: bool } lets Unhead add/remove 'dark' precisely.
20+
useHead(computed(() => ({
21+
htmlAttrs: {
22+
class: { dark: isDark.value },
23+
},
24+
})))
25+
1426
function applyClass() {
1527
if (import.meta.server) return
16-
if (colorMode.value === 'dark') {
17-
document.documentElement.classList.add('dark')
18-
} else {
19-
document.documentElement.classList.remove('dark')
20-
}
28+
// Direct DOM update for immediate visual feedback without waiting for
29+
// Unhead's next flush cycle.
30+
document.documentElement.classList.toggle('dark', colorMode.value === 'dark')
31+
document.documentElement.style.colorScheme = colorMode.value
32+
}
33+
34+
// Immediately sync Vue state from the real DOM on the client.
35+
// The inline script in app.vue applies .dark before Vue loads, so the HTML
36+
// class is always the source of truth. If useState is still at the server
37+
// default ('light') but the page is actually dark, we fix that here —
38+
// before any user interaction is possible — so the icon and toggle are correct.
39+
if (import.meta.client) {
40+
const htmlIsDark = document.documentElement.classList.contains('dark')
41+
if (htmlIsDark && colorMode.value !== 'dark') colorMode.value = 'dark'
42+
else if (!htmlIsDark && colorMode.value !== 'light') colorMode.value = 'light'
2143
}
2244

2345
/** Toggle between light and dark mode. */
@@ -38,16 +60,17 @@ export function useColorMode() {
3860
}
3961
}
4062

41-
// Read from localStorage on mount (client only)
63+
// Keep state in sync with localStorage on mount (handles tab switches,
64+
// storage events from other tabs, etc.)
4265
if (import.meta.client) {
4366
onMounted(() => {
4467
const stored = localStorage.getItem('reqcore-color-mode') as 'light' | 'dark' | null
45-
if (stored) {
46-
colorMode.value = stored
47-
} else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
48-
colorMode.value = 'dark'
68+
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
69+
const resolved: 'light' | 'dark' = stored ? stored : (prefersDark ? 'dark' : 'light')
70+
if (colorMode.value !== resolved) {
71+
colorMode.value = resolved
72+
applyClass()
4973
}
50-
applyClass()
5174
})
5275
}
5376

app/layouts/auth.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,24 @@
1+
<script setup lang="ts">
2+
import { Sun, Moon } from 'lucide-vue-next'
3+
const { isDark, toggle: toggleColorMode } = useColorMode()
4+
</script>
5+
16
<template>
27
<div class="min-h-screen flex items-center justify-center bg-surface-50 dark:bg-surface-950 p-4 relative">
3-
<div class="absolute right-4 top-4 z-10">
8+
<div class="absolute right-4 top-4 z-10 flex items-center gap-2">
9+
<ClientOnly>
10+
<button
11+
class="inline-flex items-center justify-center size-8 rounded-lg text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-200 hover:bg-surface-100 dark:hover:bg-surface-800 transition-all duration-200 cursor-pointer border-0 bg-transparent"
12+
:title="isDark ? 'Switch to light mode' : 'Switch to dark mode'"
13+
@click="toggleColorMode"
14+
>
15+
<Sun v-if="isDark" class="size-4" />
16+
<Moon v-else class="size-4" />
17+
</button>
18+
<template #fallback>
19+
<div class="size-8" aria-hidden="true" />
20+
</template>
21+
</ClientOnly>
422
<LanguageSwitcher />
523
</div>
624
<div class="w-full max-w-[540px] bg-white dark:bg-surface-900 rounded-lg shadow-sm dark:shadow-none dark:border dark:border-surface-800 p-8">

0 commit comments

Comments
 (0)