Compare commits

...
5 Commits
8 changed files with 96 additions and 37 deletions
+14 -2
View File
@@ -1,6 +1,16 @@
/** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set([ /** Field block types that don't carry a submittable value. */ const NON_DATA_BLOCKS = new Set([
'message' 'message'
]); ]);
/**
* Keys injected by the captcha widget itself, not by the form definition.
* Cloudflare Turnstile adds a hidden <input name="cf-turnstile-response"> after
* a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the
* plugin drives Turnstile end-to-end, these are legitimate artifacts — they
* must not count as "unknown fields" and trip the anti-tampering check.
*/ const CAPTCHA_KEYS = new Set([
'cf-turnstile-response',
'g-recaptcha-response'
]);
/** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000; /** Hard ceiling on a single field's length, independent of the form config. */ const MAX_FIELD_LENGTH = 5000;
/** /**
* Checks submitted data against the form's own definition, rather than trusting * Checks submitted data against the form's own definition, rather than trusting
@@ -60,8 +70,10 @@
} }
} }
// Reject outright if the payload carried keys the form doesn't define — a // Reject outright if the payload carried keys the form doesn't define — a
// sign the request wasn't produced by the rendered form. // sign the request wasn't produced by the rendered form. Captcha keys are
const unknownKeys = Object.keys(data).filter((k)=>!known.has(k)); // exempt: the widget injects them into the rendered form, so they're expected,
// not tampering.
const unknownKeys = Object.keys(data).filter((k)=>!known.has(k) && !CAPTCHA_KEYS.has(k));
if (unknownKeys.length > 0) { if (unknownKeys.length > 0) {
return { return {
kind: 'unknown_fields', kind: 'unknown_fields',
File diff suppressed because one or more lines are too long
+8 -4
View File
@@ -32,6 +32,10 @@ export type LocaleMiddlewareResult = {
location: string; location: string;
type: 'redirect'; type: 'redirect';
} | { } | {
cookie?: {
name: string;
value: string;
};
type: 'next'; type: 'next';
}; };
type CreateLocaleMiddlewareArgs = { type CreateLocaleMiddlewareArgs = {
@@ -68,10 +72,10 @@ type CreateLocaleMiddlewareArgs = {
* import { localeMiddleware } from './ipal.middleware' // created from this factory * import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function proxy(req) { * export function proxy(req) {
* const r = localeMiddleware(req) * const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next() * // Both results may carry an optional cookie — 'next' when the visitor
* const res = NextResponse.redirect(r.location) * // switched language (URL locale differs from the stored one) and consented,
* // cookie is optional: only present when the visitor consented to the * // 'redirect' on the initial locale negotiation. Set it whenever present.
* // gating category (functional by default). Guard before setting. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
+32 -12
View File
@@ -25,18 +25,43 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
* import { localeMiddleware } from './ipal.middleware' // created from this factory * import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function proxy(req) { * export function proxy(req) {
* const r = localeMiddleware(req) * const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next() * // Both results may carry an optional cookie — 'next' when the visitor
* const res = NextResponse.redirect(r.location) * // switched language (URL locale differs from the stored one) and consented,
* // cookie is optional: only present when the visitor consented to the * // 'redirect' on the initial locale negotiation. Set it whenever present.
* // gating category (functional by default). Guard before setting. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
*/ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) { */ export function createLocaleMiddleware({ config, consentCategory = 'functional', consentCookieName = CONSENT_COOKIE, cookieName = LOCALE_COOKIE_NAME }) {
// Whether the locale cookie may be written: 'necessary' is always granted;
// 'functional' (default) requires the visitor to have consented.
function mayPersistLocale(request) {
if (consentCategory === 'necessary') {
return true;
}
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
return consent?.[consentCategory] === true;
}
return function localeMiddleware(request) { return function localeMiddleware(request) {
const { pathname } = request.nextUrl; const { pathname } = request.nextUrl;
// Already locale-prefixed → nothing to do // Already locale-prefixed (e.g. the visitor switched language by
if (isValidLocale(firstSegment(pathname), config)) { // navigating to /en). Routing is fine — but if the URL's locale differs
// from the stored cookie, the visitor is *choosing* a language, and we
// should remember it — provided they consented to the gating category.
// Without consent we leave the cookie untouched: the switch works for this
// visit but isn't persisted, which is exactly the functional-cookie rule.
const urlLocale = firstSegment(pathname);
if (isValidLocale(urlLocale, config)) {
const currentCookie = request.cookies.get(cookieName)?.value ?? null;
if (currentCookie !== urlLocale && mayPersistLocale(request)) {
return {
type: 'next',
cookie: {
name: cookieName,
value: urlLocale
}
};
}
return { return {
type: 'next' type: 'next'
}; };
@@ -57,15 +82,10 @@ import { isValidLocale, LOCALE_COOKIE_NAME, negotiateLocale } from '../i18n/inde
// Without consent the locale is still detected each request (routing works), // Without consent the locale is still detected each request (routing works),
// it just isn't remembered across visits — which is the whole point of // it just isn't remembered across visits — which is the whole point of
// gating a functional cookie behind consent. // gating a functional cookie behind consent.
let mayPersist = consentCategory === 'necessary';
if (!mayPersist) {
const consent = parseConsent(request.cookies.get(consentCookieName)?.value);
mayPersist = consent?.[consentCategory] === true;
}
return { return {
type: 'redirect', type: 'redirect',
location: url.toString(), location: url.toString(),
...mayPersist ? { ...mayPersistLocale(request) ? {
cookie: { cookie: {
name: cookieName, name: cookieName,
value: locale value: locale
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@intecion/ipal-kit", "name": "@intecion/ipal-kit",
"version": "1.0.1", "version": "1.0.3",
"description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.", "description": "Intecion Payload Advanced Library — a Payload CMS 3 plugin: i18n, SEO, forms, consent, analytics, blog/archives.",
"license": "MIT", "license": "MIT",
"repository": { "repository": {
+13 -2
View File
@@ -34,6 +34,15 @@ export type FormValidationResult =
/** Field block types that don't carry a submittable value. */ /** Field block types that don't carry a submittable value. */
const NON_DATA_BLOCKS = new Set(['message']) const NON_DATA_BLOCKS = new Set(['message'])
/**
* Keys injected by the captcha widget itself, not by the form definition.
* Cloudflare Turnstile adds a hidden <input name="cf-turnstile-response"> after
* a successful challenge; reCAPTCHA adds 'g-recaptcha-response'. Since the
* plugin drives Turnstile end-to-end, these are legitimate artifacts — they
* must not count as "unknown fields" and trip the anti-tampering check.
*/
const CAPTCHA_KEYS = new Set(['cf-turnstile-response', 'g-recaptcha-response'])
/** Hard ceiling on a single field's length, independent of the form config. */ /** Hard ceiling on a single field's length, independent of the form config. */
const MAX_FIELD_LENGTH = 5000 const MAX_FIELD_LENGTH = 5000
@@ -95,8 +104,10 @@ export async function validateSubmission(
} }
// Reject outright if the payload carried keys the form doesn't define — a // Reject outright if the payload carried keys the form doesn't define — a
// sign the request wasn't produced by the rendered form. // sign the request wasn't produced by the rendered form. Captcha keys are
const unknownKeys = Object.keys(data).filter((k) => !known.has(k)) // exempt: the widget injects them into the rendered form, so they're expected,
// not tampering.
const unknownKeys = Object.keys(data).filter((k) => !known.has(k) && !CAPTCHA_KEYS.has(k))
if (unknownKeys.length > 0) { if (unknownKeys.length > 0) {
return { kind: 'unknown_fields', ok: false, reason: 'invalid' } return { kind: 'unknown_fields', ok: false, reason: 'invalid' }
} }
+26 -14
View File
@@ -21,7 +21,7 @@ type MiddlewareRequest = {
*/ */
export type LocaleMiddlewareResult = export type LocaleMiddlewareResult =
| { cookie?: { name: string; value: string }; location: string; type: 'redirect' } | { cookie?: { name: string; value: string }; location: string; type: 'redirect' }
| { type: 'next' } | { cookie?: { name: string; value: string }; type: 'next' }
type CreateLocaleMiddlewareArgs = { type CreateLocaleMiddlewareArgs = {
config: I18nConfig config: I18nConfig
@@ -66,10 +66,10 @@ function firstSegment(pathname: string): string {
* import { localeMiddleware } from './ipal.middleware' // created from this factory * import { localeMiddleware } from './ipal.middleware' // created from this factory
* export function proxy(req) { * export function proxy(req) {
* const r = localeMiddleware(req) * const r = localeMiddleware(req)
* if (r.type === 'next') return NextResponse.next() * // Both results may carry an optional cookie — 'next' when the visitor
* const res = NextResponse.redirect(r.location) * // switched language (URL locale differs from the stored one) and consented,
* // cookie is optional: only present when the visitor consented to the * // 'redirect' on the initial locale negotiation. Set it whenever present.
* // gating category (functional by default). Guard before setting. * const res = r.type === 'next' ? NextResponse.next() : NextResponse.redirect(r.location)
* if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value) * if (r.cookie) res.cookies.set(r.cookie.name, r.cookie.value)
* return res * return res
* } * }
@@ -80,11 +80,29 @@ export function createLocaleMiddleware({
consentCookieName = CONSENT_COOKIE, consentCookieName = CONSENT_COOKIE,
cookieName = LOCALE_COOKIE_NAME, cookieName = LOCALE_COOKIE_NAME,
}: CreateLocaleMiddlewareArgs) { }: CreateLocaleMiddlewareArgs) {
// Whether the locale cookie may be written: 'necessary' is always granted;
// 'functional' (default) requires the visitor to have consented.
function mayPersistLocale(request: MiddlewareRequest): boolean {
if (consentCategory === 'necessary') {return true}
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
return consent?.[consentCategory] === true
}
return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult { return function localeMiddleware(request: MiddlewareRequest): LocaleMiddlewareResult {
const { pathname } = request.nextUrl const { pathname } = request.nextUrl
// Already locale-prefixed → nothing to do // Already locale-prefixed (e.g. the visitor switched language by
if (isValidLocale(firstSegment(pathname), config)) { // navigating to /en). Routing is fine — but if the URL's locale differs
// from the stored cookie, the visitor is *choosing* a language, and we
// should remember it — provided they consented to the gating category.
// Without consent we leave the cookie untouched: the switch works for this
// visit but isn't persisted, which is exactly the functional-cookie rule.
const urlLocale = firstSegment(pathname)
if (isValidLocale(urlLocale, config)) {
const currentCookie = request.cookies.get(cookieName)?.value ?? null
if (currentCookie !== urlLocale && mayPersistLocale(request)) {
return { type: 'next', cookie: { name: cookieName, value: urlLocale } }
}
return { type: 'next' } return { type: 'next' }
} }
@@ -106,16 +124,10 @@ export function createLocaleMiddleware({
// Without consent the locale is still detected each request (routing works), // Without consent the locale is still detected each request (routing works),
// it just isn't remembered across visits — which is the whole point of // it just isn't remembered across visits — which is the whole point of
// gating a functional cookie behind consent. // gating a functional cookie behind consent.
let mayPersist = consentCategory === 'necessary'
if (!mayPersist) {
const consent = parseConsent(request.cookies.get(consentCookieName)?.value)
mayPersist = consent?.[consentCategory] === true
}
return { return {
type: 'redirect', type: 'redirect',
location: url.toString(), location: url.toString(),
...(mayPersist ? { cookie: { name: cookieName, value: locale } } : {}), ...(mayPersistLocale(request) ? { cookie: { name: cookieName, value: locale } } : {}),
} }
} }
} }