Skip to content
Open
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
17 changes: 17 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true

[*.md]
trim_trailing_whitespace = false

[{pnpm-lock.yaml,*.lock}]
indent_size = unset
insert_final_newline = unset
trim_trailing_whitespace = unset
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
\#*
node_modules
.next
.nuxt
.output
.env
.env*.local
.env.*
Expand Down
3 changes: 3 additions & 0 deletions examples/nuxt-app/app.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<template>
<NuxtPage />
</template>
22 changes: 22 additions & 0 deletions examples/nuxt-app/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export default defineNuxtConfig({
compatibilityDate: '2025-01-01',
modules: ['@csrf-armor/nuxt'],
app: {
head: {
titleTemplate: '%s | CSRF Armor',
title: 'Nuxt Demo',
meta: [
{ name: 'description', content: 'Interactive demo of CSRF protection strategies using @csrf-armor/nuxt' },
],
},
},
csrfArmor: {
strategy: 'signed-double-submit',
// Override in production via NUXT_CSRF_ARMOR_SECRET env variable
secret: 'super-secret-key-for-dev-only-32-chars-long-enough',
token: { expiry: 3600, fieldName: '_csrf' },
cookie: { secure: false /* must be true in production (HTTPS) */, name: 'x-csrf-token' },
allowedOrigins: ['http://localhost:3000'],
},
devtools: { enabled: false },
})
18 changes: 18 additions & 0 deletions examples/nuxt-app/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
{
"name": "csrf-armor-nuxt-example",
"version": "1.0.0",
"description": "Example Nuxt app using @csrf-armor/nuxt",
"private": true,
"type": "module",
"scripts": {
"dev": "nuxt dev",
"start": "nuxt build && nuxt preview"
},
"dependencies": {
"@csrf-armor/nuxt": "workspace:*",
"nuxt": "^4.3.1",
"vue": "^3.5.0"
},
"author": "Jordan Labrosse",
"license": "MIT"
}
179 changes: 179 additions & 0 deletions examples/nuxt-app/pages/demo/[strategy].vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
<script setup lang="ts">
const route = useRoute()
const rawStrategy = route.params.strategy
const strategy = Array.isArray(rawStrategy) ? rawStrategy[0] : rawStrategy

useSeoMeta({
title: `${strategy} Demo`,
description: `Interactive demo of the ${strategy} CSRF protection strategy.`,
})

const { csrfToken, csrfFetch } = useCsrfToken()

const formData = ref('')
const result = ref<{ success: boolean; message: string } | null>(null)
const error = ref<string | null>(null)

/** Strategy-specific notes matching the express example. */
const strategyNotes: Record<string, string> = {
'double-submit':
'Compares a token sent in a cookie with a token sent in the request body/header. No secret needed.',
'signed-double-submit':
'Similar to double-submit, but the cookie token is signed. Requires a secret.',
'signed-token':
'A stateless strategy where a signed token is generated and provided to the client. Requires a secret.',
'origin-check':
'Validates the Origin and/or Referer headers against allowed origins. No explicit token in form.',
hybrid:
'Combines signed-token and origin-check. Requires a secret and allowedOrigins.',
}

async function handleSubmit() {
result.value = null
error.value = null

try {
const response = await csrfFetch('/api/submit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ data: formData.value, strategy }),
})

if (!response.ok) {
const errorData = await response.json().catch(() => null)
error.value = errorData?.data?.reason ?? `Request failed with status ${response.status}`
return
}

result.value = await response.json()
} catch (err) {
error.value = err instanceof Error ? err.message : 'Unexpected error'
}
}
</script>

<template>
<div class="container">
<h1>
Strategy: <code>{{ strategy }}</code>
</h1>

<NuxtLink to="/">&larr; Back to Strategy List</NuxtLink>

<p v-if="strategy !== 'origin-check'" class="token">
CSRF Token: <code>{{ csrfToken ?? 'N/A' }}</code>
</p>

<p v-if="strategyNotes[strategy]" class="notes">
<strong>Notes:</strong> {{ strategyNotes[strategy] }}
</p>

<form @submit.prevent="handleSubmit">
<label for="data">Enter some data:</label>
<input
id="data"
v-model="formData"
type="text"
:placeholder="`Hello ${strategy} CSRF`"
/>
<button type="submit">Submit with {{ strategy }}</button>
</form>

<div v-if="result" class="success">
{{ result.message }}
</div>

<div v-if="error" class="error">
Error: {{ error }}
</div>

<hr />
<p class="hint">
Try submitting with a modified or missing token (if applicable) to see it fail.
</p>
</div>
</template>

<style scoped>
.container {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}

code {
background: #f0f0f0;
padding: 0.15rem 0.4rem;
border-radius: 3px;
font-size: 0.9em;
word-break: break-all;
}

.token {
margin: 1rem 0;
}

.notes {
background: #f8f8f8;
padding: 0.75rem;
border-left: 3px solid #00dc82;
margin: 1rem 0;
}

form {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin: 1.5rem 0;
}

input {
padding: 0.5rem;
border: 1px solid #ccc;
border-radius: 4px;
}

button {
padding: 0.5rem 1rem;
background: #00dc82;
color: #fff;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
}

button:hover {
background: #00b86b;
}

.success {
color: #166534;
background: #dcfce7;
padding: 0.75rem;
border-radius: 4px;
margin-top: 1rem;
}

.error {
color: #991b1b;
background: #fee2e2;
padding: 0.75rem;
border-radius: 4px;
margin-top: 1rem;
}

.hint {
color: #666;
font-size: 0.9rem;
}

a {
color: #00dc82;
text-decoration: none;
}

a:hover {
text-decoration: underline;
}
</style>
53 changes: 53 additions & 0 deletions examples/nuxt-app/pages/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
<script setup lang="ts">
useSeoMeta({
title: 'Strategy Selector',
description: 'Select a CSRF protection strategy to test with the @csrf-armor/nuxt module.',
})

const strategies = [
'double-submit',
'signed-double-submit',
'signed-token',
'origin-check',
'hybrid',
] as const
</script>

<template>
<div class="container">
<h1>CSRF Armor Nuxt - Strategy Demo</h1>
<p>Select a CSRF protection strategy to test:</p>
<ul>
<li v-for="strategy in strategies" :key="strategy">
<NuxtLink :to="`/demo/${strategy}`">{{ strategy }}</NuxtLink>
</li>
</ul>
</div>
</template>

<style scoped>
.container {
max-width: 640px;
margin: 2rem auto;
font-family: system-ui, sans-serif;
}

ul {
list-style: none;
padding: 0;
}

li {
margin: 0.5rem 0;
}

a {
color: #00dc82;
text-decoration: none;
font-size: 1.1rem;
}

a:hover {
text-decoration: underline;
}
</style>
10 changes: 10 additions & 0 deletions examples/nuxt-app/server/api/submit.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
/** API endpoint that handles form submissions. CSRF is validated by the middleware. */
export default defineEventHandler(async (event) => {
const body = await readBody(event)
return {
success: true,
message: 'Form submitted successfully!',
data: body.data,
strategy: body.strategy,
}
})
1 change: 1 addition & 0 deletions examples/nuxt-app/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "extends": "./.nuxt/tsconfig.json" }
8 changes: 6 additions & 2 deletions packages/nextjs/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,12 @@ export function getCsrfToken(config?: CsrfClientConfig): string | null {
const csrfCookie = cookies.find((c) => c.trim().startsWith(`${cookieName}=`));

if (csrfCookie) {
const [, value] = csrfCookie.split('=');
return decodeURIComponent(value?.trim() ?? '');
const value = csrfCookie.slice(csrfCookie.indexOf('=') + 1).trim();
try {
return decodeURIComponent(value);
} catch {
return value;
}
}

// Fallback to meta tag
Expand Down
Loading
Loading