start-vibing-stacks 2.1.1 → 2.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/detector.js +4 -6
- package/dist/index.js +63 -2
- package/dist/scanner.d.ts +12 -0
- package/dist/scanner.js +480 -0
- package/dist/setup.js +29 -0
- package/dist/types.d.ts +20 -0
- package/dist/ui.js +1 -1
- package/package.json +1 -1
- package/stacks/_shared/hooks/user-prompt-submit.ts +26 -2
- package/stacks/frontend/react-inertia/skills/inertia-react/SKILL.md +342 -0
- package/stacks/frontend/react-inertia/skills/react-standards/SKILL.md +267 -0
- package/stacks/php/skills/api-security/SKILL.md +431 -0
- package/stacks/php/skills/laravel-octane/SKILL.md +155 -53
- package/stacks/php/skills/laravel-patterns/SKILL.md +244 -39
- package/stacks/php/skills/php-patterns/SKILL.md +113 -53
- package/stacks/php/skills/security-scan-php/SKILL.md +161 -43
- package/stacks/php/stack.json +19 -6
- package/templates/CLAUDE-php.md +108 -29
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# React 19+ Standards (with Inertia.js)
|
|
2
|
+
|
|
3
|
+
## Version Requirements
|
|
4
|
+
|
|
5
|
+
- **ReactJS >= 19** — MANDATORY
|
|
6
|
+
- **TailwindCSS >= 4** — MANDATORY
|
|
7
|
+
- **Inertia.js >= 2** — MANDATORY
|
|
8
|
+
|
|
9
|
+
## Translation Pattern (via Inertia shared props)
|
|
10
|
+
|
|
11
|
+
```tsx
|
|
12
|
+
import __ from '@/Utils/translate';
|
|
13
|
+
|
|
14
|
+
// CORRECT: Translations as CONST at the top, BEFORE hooks
|
|
15
|
+
const LABELS = {
|
|
16
|
+
title: __('dashboard.title'),
|
|
17
|
+
save: __('common.save'),
|
|
18
|
+
cancel: __('common.cancel'),
|
|
19
|
+
errorRequired: __('errors.field_required'),
|
|
20
|
+
welcome: __('dashboard.welcome', { name: 'User' }),
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export default function Dashboard() {
|
|
24
|
+
const [data, setData] = useState(null);
|
|
25
|
+
|
|
26
|
+
return <h1>{LABELS.title}</h1>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// WRONG: __() inside JSX (Hook violation — usePage() is called internally)
|
|
30
|
+
return <h1>{__('dashboard.title')}</h1>; // NEVER
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
**Rules:**
|
|
34
|
+
- Translations in `CONST` variables before state hooks
|
|
35
|
+
- New strings must be added to `lang/en/*.php` AND `lang/pt/*.php`
|
|
36
|
+
- Error strings centralized in `lang/*/errors.php`
|
|
37
|
+
- Use replacements for dynamic values: `__('key', { name: value })`
|
|
38
|
+
|
|
39
|
+
## Debug Logging
|
|
40
|
+
|
|
41
|
+
```tsx
|
|
42
|
+
const ENABLE_DASHBOARD_DEBUG = false;
|
|
43
|
+
|
|
44
|
+
const debugLog = (...args: unknown[]) => {
|
|
45
|
+
if (ENABLE_DASHBOARD_DEBUG) console.log('[Dashboard]', ...args);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
export default function Dashboard() {
|
|
49
|
+
debugLog('Rendering with data:', data);
|
|
50
|
+
}
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
**Rule:** Never leave raw `console.log`. Always use controlled debug pattern.
|
|
54
|
+
|
|
55
|
+
## TailwindCSS Class Organization
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// CORRECT: Classes as CONST — clean JSX
|
|
59
|
+
const STYLES = {
|
|
60
|
+
container: 'flex flex-col gap-4 p-6 bg-white rounded-lg shadow-sm',
|
|
61
|
+
title: 'text-2xl font-bold text-gray-900',
|
|
62
|
+
button: 'px-4 py-2 bg-blue-600 text-white rounded-md hover:bg-blue-700 transition',
|
|
63
|
+
grid: 'grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6',
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
export default function Dashboard() {
|
|
67
|
+
return (
|
|
68
|
+
<div className={STYLES.container}>
|
|
69
|
+
<h1 className={STYLES.title}>{LABELS.title}</h1>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// WRONG: Inline class soup
|
|
75
|
+
<div className="flex flex-col gap-4 p-6 bg-white rounded-lg shadow-sm"> // NEVER
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## SVG Icons
|
|
79
|
+
|
|
80
|
+
```tsx
|
|
81
|
+
// CORRECT: Separate files, import with ?react
|
|
82
|
+
import CheckIcon from '@/Icons/CheckIcon.svg?react';
|
|
83
|
+
import { CheckIcon, AlertIcon } from '@/Icons';
|
|
84
|
+
|
|
85
|
+
// WRONG: Inline SVG (bloats JSX)
|
|
86
|
+
<svg viewBox="0 0 24 24">...</svg> // NEVER
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Structure:**
|
|
90
|
+
```
|
|
91
|
+
resources/js/Icons/
|
|
92
|
+
├── index.js # Barrel export
|
|
93
|
+
├── CheckIcon.svg
|
|
94
|
+
├── AlertIcon.svg
|
|
95
|
+
└── SpinnerIcon.svg
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## Inertia.js Hooks & Navigation
|
|
99
|
+
|
|
100
|
+
### Accessing Shared Props
|
|
101
|
+
|
|
102
|
+
```tsx
|
|
103
|
+
import { usePage } from '@inertiajs/react';
|
|
104
|
+
|
|
105
|
+
export default function Header() {
|
|
106
|
+
const { auth, locale, flash } = usePage().props;
|
|
107
|
+
|
|
108
|
+
return (
|
|
109
|
+
<nav>
|
|
110
|
+
<span>{auth.user?.name}</span>
|
|
111
|
+
{flash.success && <Alert>{flash.success}</Alert>}
|
|
112
|
+
</nav>
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
### Forms with useForm
|
|
118
|
+
|
|
119
|
+
```tsx
|
|
120
|
+
import { useForm } from '@inertiajs/react';
|
|
121
|
+
|
|
122
|
+
export default function CreateOrder() {
|
|
123
|
+
const { data, setData, post, processing, errors } = useForm({
|
|
124
|
+
product_id: '',
|
|
125
|
+
quantity: 1,
|
|
126
|
+
notes: '',
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
const handleSubmit = (e) => {
|
|
130
|
+
e.preventDefault();
|
|
131
|
+
post(route('orders.store'));
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
return (
|
|
135
|
+
<form onSubmit={handleSubmit}>
|
|
136
|
+
<Input
|
|
137
|
+
value={data.product_id}
|
|
138
|
+
onChange={(e) => setData('product_id', e.target.value)}
|
|
139
|
+
error={errors.product_id}
|
|
140
|
+
/>
|
|
141
|
+
<Button type="submit" loading={processing}>
|
|
142
|
+
{processing ? <LoadingSpinner /> : LABELS.save}
|
|
143
|
+
</Button>
|
|
144
|
+
</form>
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
**Rules:**
|
|
150
|
+
- Use `useForm` for ALL form submissions (handles CSRF, errors, loading)
|
|
151
|
+
- `processing` boolean for button loading states
|
|
152
|
+
- `errors` object maps to Form Request validation errors
|
|
153
|
+
- Never use `fetch()` or `axios` for form submissions
|
|
154
|
+
|
|
155
|
+
### Navigation
|
|
156
|
+
|
|
157
|
+
```tsx
|
|
158
|
+
import { Link, router } from '@inertiajs/react';
|
|
159
|
+
|
|
160
|
+
// SPA links (no full page reload)
|
|
161
|
+
<Link href={route('orders.index')}>Orders</Link>
|
|
162
|
+
|
|
163
|
+
// Programmatic navigation
|
|
164
|
+
router.visit(route('dashboard'));
|
|
165
|
+
|
|
166
|
+
// Partial reload (only refresh specific props)
|
|
167
|
+
router.reload({ only: ['stats', 'recentOrders'] });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
## Loading States
|
|
171
|
+
|
|
172
|
+
```tsx
|
|
173
|
+
// CORRECT: Always show loading feedback
|
|
174
|
+
export default function DataTable() {
|
|
175
|
+
const [loading, setLoading] = useState(true);
|
|
176
|
+
|
|
177
|
+
if (loading) {
|
|
178
|
+
return <SectionLoader />;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return <Table data={data} />;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Button loading (from useForm)
|
|
185
|
+
<Button onClick={handleSave} disabled={processing}>
|
|
186
|
+
{processing ? <LoadingSpinner /> : LABELS.save}
|
|
187
|
+
</Button>
|
|
188
|
+
```
|
|
189
|
+
|
|
190
|
+
**Rule:** Every data-heavy section needs a loading state.
|
|
191
|
+
|
|
192
|
+
## Modal Data Flow
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
interface EditModalProps {
|
|
196
|
+
item: Item;
|
|
197
|
+
isOpen: boolean;
|
|
198
|
+
onClose: () => void;
|
|
199
|
+
onUpdated: () => void;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function EditModal({ item, isOpen, onClose, onUpdated }: EditModalProps) {
|
|
203
|
+
const { data, setData, put, processing } = useForm({
|
|
204
|
+
name: item.name,
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
const handleSave = (e) => {
|
|
208
|
+
e.preventDefault();
|
|
209
|
+
put(route('items.update', item.id), {
|
|
210
|
+
onSuccess: () => {
|
|
211
|
+
onUpdated();
|
|
212
|
+
onClose();
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// Parent: use router.reload for refresh after modal mutation
|
|
219
|
+
<EditModal
|
|
220
|
+
item={selectedItem}
|
|
221
|
+
isOpen={showModal}
|
|
222
|
+
onClose={() => setShowModal(false)}
|
|
223
|
+
onUpdated={() => router.reload({ only: ['items'] })}
|
|
224
|
+
/>
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
## Third-Party Libraries (Charts)
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
// CORRECT: Let React handle re-rendering
|
|
231
|
+
{chartData && (
|
|
232
|
+
<ApexChart
|
|
233
|
+
key={JSON.stringify(chartData)}
|
|
234
|
+
options={chartOptions}
|
|
235
|
+
series={chartData}
|
|
236
|
+
type="area"
|
|
237
|
+
/>
|
|
238
|
+
)}
|
|
239
|
+
|
|
240
|
+
// WRONG: Manual DOM manipulation
|
|
241
|
+
const chartRef = useRef(null);
|
|
242
|
+
chartRef.current.updateSeries(newData); // NEVER
|
|
243
|
+
|
|
244
|
+
// Memoize expensive computations
|
|
245
|
+
const processedData = useMemo(() => {
|
|
246
|
+
return heavyTransform(rawData);
|
|
247
|
+
}, [rawData]);
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
**Rules:**
|
|
251
|
+
- No `useRef` for updating third-party components
|
|
252
|
+
- Conditional rendering: `data && <Component />`
|
|
253
|
+
- `useMemo` for expensive computations
|
|
254
|
+
- Loading states before rendering charts
|
|
255
|
+
|
|
256
|
+
## Forbidden Patterns
|
|
257
|
+
|
|
258
|
+
| Pattern | Reason | Use Instead |
|
|
259
|
+
|---------|--------|-------------|
|
|
260
|
+
| `fetch()` / `axios` for pages | Bypasses Inertia | `Inertia::render()` props |
|
|
261
|
+
| `__()` inside JSX | Hook violation | CONST at top |
|
|
262
|
+
| Inline SVGs | Bloats components | SVG files + `?react` |
|
|
263
|
+
| `<a href>` for internal links | Full reload | `<Link href>` |
|
|
264
|
+
| `window.location` | Full reload | `router.visit()` |
|
|
265
|
+
| Raw `console.log` | Uncontrolled | Debug constant pattern |
|
|
266
|
+
| Inline Tailwind soup | Unreadable | STYLES const object |
|
|
267
|
+
| `axios.post()` for forms | No CSRF/errors | `useForm().post()` |
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# API Security — NSA-Level Hardening for Laravel + Octane
|
|
2
|
+
|
|
3
|
+
**ALWAYS invoke when building APIs, auth endpoints, or handling user input.**
|
|
4
|
+
|
|
5
|
+
## Security Layers
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
Internet → Cloudflare WAF → Rate Limiting → CORS → Auth Middleware
|
|
9
|
+
→ Input Validation → Business Logic → Output Sanitization → Response
|
|
10
|
+
|
|
11
|
+
Every layer is a wall. Assume the previous one failed.
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## 1. Authentication (Sanctum + Octane)
|
|
15
|
+
|
|
16
|
+
### Token-Based (API)
|
|
17
|
+
|
|
18
|
+
```php
|
|
19
|
+
// config/sanctum.php
|
|
20
|
+
'expiration' => 60 * 24, // 24h token expiry (MANDATORY)
|
|
21
|
+
'token_prefix' => 'flk_', // Prefix for easy log scanning
|
|
22
|
+
|
|
23
|
+
// Issue token with abilities (least privilege)
|
|
24
|
+
$token = $user->createToken('api-client', [
|
|
25
|
+
'read:leads',
|
|
26
|
+
'write:leads',
|
|
27
|
+
// NOT '*' — never wildcard abilities
|
|
28
|
+
])->plainTextToken;
|
|
29
|
+
|
|
30
|
+
// Middleware: check specific ability
|
|
31
|
+
Route::middleware(['auth:sanctum', 'ability:read:leads'])->group(function () {
|
|
32
|
+
Route::get('/api/v1/leads', [LeadController::class, 'index']);
|
|
33
|
+
});
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
### Token Rotation
|
|
37
|
+
|
|
38
|
+
```php
|
|
39
|
+
// Rotate on every sensitive action
|
|
40
|
+
public function changePassword(Request $request): JsonResponse
|
|
41
|
+
{
|
|
42
|
+
// ... change password logic
|
|
43
|
+
|
|
44
|
+
// Revoke ALL tokens (force re-login everywhere)
|
|
45
|
+
$request->user()->tokens()->delete();
|
|
46
|
+
|
|
47
|
+
// Issue fresh token
|
|
48
|
+
$newToken = $request->user()->createToken('session', ['*'])->plainTextToken;
|
|
49
|
+
|
|
50
|
+
return $this->success(['token' => $newToken]);
|
|
51
|
+
}
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Octane Session Safety
|
|
55
|
+
|
|
56
|
+
```php
|
|
57
|
+
// app/Providers/AppServiceProvider.php
|
|
58
|
+
use Laravel\Octane\Facades\Octane;
|
|
59
|
+
|
|
60
|
+
public function boot(): void
|
|
61
|
+
{
|
|
62
|
+
// MANDATORY: flush auth state between requests
|
|
63
|
+
Octane::prepare(function ($sandbox) {
|
|
64
|
+
$sandbox->forgetScopedInstances();
|
|
65
|
+
$sandbox->flushDatabaseConnections();
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
## 2. Input Validation (Trust NOTHING)
|
|
71
|
+
|
|
72
|
+
### FormRequest (Always)
|
|
73
|
+
|
|
74
|
+
```php
|
|
75
|
+
// app/Http/Requests/StoreLeadRequest.php
|
|
76
|
+
class StoreLeadRequest extends FormRequest
|
|
77
|
+
{
|
|
78
|
+
public function authorize(): bool
|
|
79
|
+
{
|
|
80
|
+
return $this->user()->tokenCan('write:leads');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
public function rules(): array
|
|
84
|
+
{
|
|
85
|
+
return [
|
|
86
|
+
'name' => ['required', 'string', 'min:2', 'max:255'],
|
|
87
|
+
'email' => ['required', 'email:rfc,dns', 'max:320'], // RFC + DNS check
|
|
88
|
+
'phone' => ['nullable', 'string', 'regex:/^\+?[1-9]\d{1,14}$/'], // E.164
|
|
89
|
+
'domain_id' => ['required', 'uuid', 'exists:domains,id'],
|
|
90
|
+
'metadata' => ['nullable', 'json', 'max:10000'], // Size limit on JSON
|
|
91
|
+
'tags' => ['nullable', 'array', 'max:10'],
|
|
92
|
+
'tags.*' => ['string', 'max:50', 'alpha_dash'],
|
|
93
|
+
];
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Sanitize AFTER validation
|
|
97
|
+
public function validated($key = null, $default = null): array
|
|
98
|
+
{
|
|
99
|
+
$data = parent::validated($key, $default);
|
|
100
|
+
$data['email'] = strtolower(trim($data['email']));
|
|
101
|
+
$data['name'] = strip_tags($data['name']);
|
|
102
|
+
return $data;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
### SQL Injection Prevention
|
|
108
|
+
|
|
109
|
+
```php
|
|
110
|
+
// ✅ ALWAYS Eloquent or parameterized
|
|
111
|
+
Lead::where('email', $request->validated('email'))->first();
|
|
112
|
+
|
|
113
|
+
// ✅ Raw with bindings
|
|
114
|
+
DB::select('SELECT * FROM leads WHERE email = ?', [$email]);
|
|
115
|
+
|
|
116
|
+
// ❌ NEVER interpolate user input
|
|
117
|
+
DB::select("SELECT * FROM leads WHERE email = '{$email}'"); // ❌ SQL INJECTION
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
### Mass Assignment Protection
|
|
121
|
+
|
|
122
|
+
```php
|
|
123
|
+
class Lead extends Model
|
|
124
|
+
{
|
|
125
|
+
// Explicit fillable (whitelist approach)
|
|
126
|
+
protected $fillable = [
|
|
127
|
+
'name', 'email', 'phone', 'domain_id', 'metadata',
|
|
128
|
+
];
|
|
129
|
+
|
|
130
|
+
// NEVER: protected $guarded = []; ← allows EVERYTHING
|
|
131
|
+
}
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## 3. Rate Limiting (Multi-Layer)
|
|
135
|
+
|
|
136
|
+
```php
|
|
137
|
+
// app/Providers/AppServiceProvider.php
|
|
138
|
+
use Illuminate\Cache\RateLimiting\Limit;
|
|
139
|
+
use Illuminate\Support\Facades\RateLimiter;
|
|
140
|
+
|
|
141
|
+
public function boot(): void
|
|
142
|
+
{
|
|
143
|
+
// Global API limit
|
|
144
|
+
RateLimiter::for('api', function ($request) {
|
|
145
|
+
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
// Strict limit on auth endpoints
|
|
149
|
+
RateLimiter::for('auth', function ($request) {
|
|
150
|
+
return [
|
|
151
|
+
Limit::perMinute(5)->by($request->ip()), // 5/min per IP
|
|
152
|
+
Limit::perHour(20)->by($request->ip()), // 20/hour per IP
|
|
153
|
+
Limit::perDay(50)->by($request->ip()), // 50/day per IP
|
|
154
|
+
];
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Strict limit on sensitive actions
|
|
158
|
+
RateLimiter::for('sensitive', function ($request) {
|
|
159
|
+
return Limit::perMinute(3)->by($request->user()->id); // 3/min per user
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Webhook endpoints
|
|
163
|
+
RateLimiter::for('webhooks', function ($request) {
|
|
164
|
+
return Limit::perMinute(100)->by($request->ip());
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Routes
|
|
169
|
+
Route::middleware('throttle:auth')->group(function () {
|
|
170
|
+
Route::post('/login', [AuthController::class, 'login']);
|
|
171
|
+
Route::post('/register', [AuthController::class, 'register']);
|
|
172
|
+
Route::post('/forgot-password', [AuthController::class, 'forgotPassword']);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
Route::middleware('throttle:sensitive')->group(function () {
|
|
176
|
+
Route::post('/change-password', [AuthController::class, 'changePassword']);
|
|
177
|
+
Route::post('/change-email', [AuthController::class, 'changeEmail']);
|
|
178
|
+
Route::delete('/account', [AuthController::class, 'deleteAccount']);
|
|
179
|
+
});
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
## 4. CORS (Restrictive)
|
|
183
|
+
|
|
184
|
+
```php
|
|
185
|
+
// config/cors.php
|
|
186
|
+
return [
|
|
187
|
+
'paths' => ['api/*'],
|
|
188
|
+
'allowed_methods' => ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'],
|
|
189
|
+
'allowed_origins' => [
|
|
190
|
+
env('APP_URL'), // Only your domain
|
|
191
|
+
// NOT '*' — NEVER wildcard in production
|
|
192
|
+
],
|
|
193
|
+
'allowed_headers' => [
|
|
194
|
+
'Content-Type', 'Authorization', 'X-Requested-With',
|
|
195
|
+
'X-Timezone', 'Accept', 'X-CSRF-TOKEN',
|
|
196
|
+
],
|
|
197
|
+
'exposed_headers' => ['X-Request-ID', 'Retry-After'],
|
|
198
|
+
'max_age' => 86400, // 24h preflight cache
|
|
199
|
+
'supports_credentials' => true,
|
|
200
|
+
];
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
## 5. Security Headers Middleware
|
|
204
|
+
|
|
205
|
+
```php
|
|
206
|
+
// app/Http/Middleware/SecurityHeaders.php
|
|
207
|
+
class SecurityHeaders
|
|
208
|
+
{
|
|
209
|
+
public function handle($request, Closure $next)
|
|
210
|
+
{
|
|
211
|
+
$response = $next($request);
|
|
212
|
+
|
|
213
|
+
return $response
|
|
214
|
+
->header('X-Content-Type-Options', 'nosniff')
|
|
215
|
+
->header('X-Frame-Options', 'DENY')
|
|
216
|
+
->header('X-XSS-Protection', '0') // Modern browsers use CSP instead
|
|
217
|
+
->header('Referrer-Policy', 'strict-origin-when-cross-origin')
|
|
218
|
+
->header('Permissions-Policy', 'camera=(), microphone=(), geolocation=()')
|
|
219
|
+
->header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains; preload')
|
|
220
|
+
->header('X-Request-ID', $request->attributes->get('request_id', (string) Str::uuid()))
|
|
221
|
+
->header('Content-Security-Policy', implode('; ', [
|
|
222
|
+
"default-src 'self'",
|
|
223
|
+
"script-src 'self' 'unsafe-inline' https://js.stripe.com",
|
|
224
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
225
|
+
"img-src 'self' data: https:",
|
|
226
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
227
|
+
"connect-src 'self' https://api.stripe.com",
|
|
228
|
+
"frame-src https://js.stripe.com",
|
|
229
|
+
"object-src 'none'",
|
|
230
|
+
"base-uri 'self'",
|
|
231
|
+
]));
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
## 6. Output Sanitization
|
|
237
|
+
|
|
238
|
+
```php
|
|
239
|
+
// app/Http/Resources/LeadResource.php
|
|
240
|
+
class LeadResource extends JsonResource
|
|
241
|
+
{
|
|
242
|
+
use FormatsDatesForApi;
|
|
243
|
+
|
|
244
|
+
public function toArray(Request $request): array
|
|
245
|
+
{
|
|
246
|
+
return [
|
|
247
|
+
'id' => $this->id,
|
|
248
|
+
'name' => e($this->name), // HTML entity encode
|
|
249
|
+
'email' => $this->email,
|
|
250
|
+
'status' => $this->status->value,
|
|
251
|
+
'created_at' => $this->formatDateTime($this->created_at, $request),
|
|
252
|
+
// NEVER expose:
|
|
253
|
+
// 'password' → NEVER
|
|
254
|
+
// 'api_token' → NEVER
|
|
255
|
+
// 'ip_address' → only if admin
|
|
256
|
+
// 'remember_token' → NEVER
|
|
257
|
+
];
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Model hidden attributes (defense in depth)
|
|
262
|
+
class User extends Authenticatable
|
|
263
|
+
{
|
|
264
|
+
protected $hidden = [
|
|
265
|
+
'password',
|
|
266
|
+
'remember_token',
|
|
267
|
+
'two_factor_secret',
|
|
268
|
+
'two_factor_recovery_codes',
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## 7. Encryption at Rest
|
|
274
|
+
|
|
275
|
+
```php
|
|
276
|
+
// Encrypt sensitive data in database
|
|
277
|
+
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
278
|
+
|
|
279
|
+
class ApiCredential extends Model
|
|
280
|
+
{
|
|
281
|
+
// Laravel encrypted cast (AES-256-CBC)
|
|
282
|
+
protected $casts = [
|
|
283
|
+
'api_key' => 'encrypted',
|
|
284
|
+
'api_secret' => 'encrypted',
|
|
285
|
+
'webhook_secret' => 'encrypted',
|
|
286
|
+
];
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// For searchable encrypted fields
|
|
290
|
+
use Illuminate\Support\Facades\Crypt;
|
|
291
|
+
|
|
292
|
+
class Lead extends Model
|
|
293
|
+
{
|
|
294
|
+
// Store hash for lookup, encrypted value for display
|
|
295
|
+
protected static function booted(): void
|
|
296
|
+
{
|
|
297
|
+
static::creating(function ($lead) {
|
|
298
|
+
$lead->email_hash = hash('sha256', strtolower($lead->email));
|
|
299
|
+
$lead->email_encrypted = Crypt::encryptString($lead->email);
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
## 8. Audit Logging
|
|
306
|
+
|
|
307
|
+
```php
|
|
308
|
+
// app/Traits/Auditable.php
|
|
309
|
+
trait Auditable
|
|
310
|
+
{
|
|
311
|
+
protected static function bootAuditable(): void
|
|
312
|
+
{
|
|
313
|
+
foreach (['created', 'updated', 'deleted'] as $event) {
|
|
314
|
+
static::$event(function ($model) use ($event) {
|
|
315
|
+
AuditLog::create([
|
|
316
|
+
'auditable_type' => $model->getMorphClass(),
|
|
317
|
+
'auditable_id' => $model->getKey(),
|
|
318
|
+
'event' => $event,
|
|
319
|
+
'old_values' => $event === 'updated' ? $model->getOriginal() : null,
|
|
320
|
+
'new_values' => $event !== 'deleted' ? $model->getAttributes() : null,
|
|
321
|
+
'user_id' => auth()->id(),
|
|
322
|
+
'ip_address' => request()->ip(),
|
|
323
|
+
'user_agent' => request()->userAgent(),
|
|
324
|
+
]);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Usage on sensitive models
|
|
331
|
+
class Lead extends Model
|
|
332
|
+
{
|
|
333
|
+
use HasUuids, Auditable;
|
|
334
|
+
}
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## 9. Brute Force Protection
|
|
338
|
+
|
|
339
|
+
```php
|
|
340
|
+
// app/Http/Controllers/Auth/LoginController.php
|
|
341
|
+
public function login(LoginRequest $request): JsonResponse
|
|
342
|
+
{
|
|
343
|
+
$key = 'login_attempts:' . $request->ip() . ':' . Str::lower($request->email);
|
|
344
|
+
|
|
345
|
+
// Check lockout (progressive delay)
|
|
346
|
+
$attempts = (int) Cache::get($key, 0);
|
|
347
|
+
if ($attempts >= 5) {
|
|
348
|
+
$lockoutMinutes = min(pow(2, $attempts - 5), 60); // 1, 2, 4, 8, 16, 32, 60 min
|
|
349
|
+
$ttl = Cache::get("{$key}:lockout");
|
|
350
|
+
if ($ttl && now()->lt($ttl)) {
|
|
351
|
+
return $this->error('Too many attempts. Try again later.', 429);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (!Auth::attempt($request->validated())) {
|
|
356
|
+
// Increment with exponential TTL
|
|
357
|
+
Cache::put($key, $attempts + 1, now()->addHours(1));
|
|
358
|
+
if ($attempts + 1 >= 5) {
|
|
359
|
+
$lockoutMinutes = min(pow(2, $attempts - 4), 60);
|
|
360
|
+
Cache::put("{$key}:lockout", now()->addMinutes($lockoutMinutes), now()->addMinutes($lockoutMinutes));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Generic message (don't reveal if user exists)
|
|
364
|
+
return $this->error('Invalid credentials', 401);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Reset on success
|
|
368
|
+
Cache::forget($key);
|
|
369
|
+
Cache::forget("{$key}:lockout");
|
|
370
|
+
|
|
371
|
+
$token = $request->user()->createToken('session')->plainTextToken;
|
|
372
|
+
return $this->success(['token' => $token]);
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
## 10. Request ID Tracking
|
|
377
|
+
|
|
378
|
+
```php
|
|
379
|
+
// app/Http/Middleware/RequestId.php
|
|
380
|
+
class RequestId
|
|
381
|
+
{
|
|
382
|
+
public function handle($request, Closure $next)
|
|
383
|
+
{
|
|
384
|
+
$requestId = $request->header('X-Request-ID', (string) Str::uuid());
|
|
385
|
+
$request->attributes->set('request_id', $requestId);
|
|
386
|
+
|
|
387
|
+
// Add to all log entries in this request
|
|
388
|
+
Log::shareContext(['request_id' => $requestId]);
|
|
389
|
+
|
|
390
|
+
$response = $next($request);
|
|
391
|
+
$response->header('X-Request-ID', $requestId);
|
|
392
|
+
|
|
393
|
+
return $response;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
```
|
|
397
|
+
|
|
398
|
+
## Security Checklist — Before Deploy
|
|
399
|
+
|
|
400
|
+
- [ ] All endpoints have auth middleware
|
|
401
|
+
- [ ] All input validated via FormRequest
|
|
402
|
+
- [ ] Rate limiting on auth + sensitive endpoints
|
|
403
|
+
- [ ] CORS restricted to your domain only
|
|
404
|
+
- [ ] Security headers middleware active
|
|
405
|
+
- [ ] Sensitive fields `$hidden` on models
|
|
406
|
+
- [ ] API keys encrypted at rest
|
|
407
|
+
- [ ] Audit logging on sensitive models
|
|
408
|
+
- [ ] Brute force protection on login
|
|
409
|
+
- [ ] Request ID tracking in all logs
|
|
410
|
+
- [ ] No `env()` in code (only `config()`)
|
|
411
|
+
- [ ] No `*` in token abilities
|
|
412
|
+
- [ ] No `$guarded = []` on models
|
|
413
|
+
- [ ] CSP headers configured
|
|
414
|
+
- [ ] HSTS enabled
|
|
415
|
+
- [ ] Webhook signatures verified
|
|
416
|
+
- [ ] Error responses don't expose internals
|
|
417
|
+
|
|
418
|
+
## FORBIDDEN
|
|
419
|
+
|
|
420
|
+
| ❌ Don't | ✅ Do |
|
|
421
|
+
|---|---|
|
|
422
|
+
| `$guarded = []` | Explicit `$fillable` |
|
|
423
|
+
| `'allowed_origins' => ['*']` | Your domain only |
|
|
424
|
+
| `createToken('x', ['*'])` | Specific abilities |
|
|
425
|
+
| `"WHERE email = '$email'"` | Parameterized queries |
|
|
426
|
+
| `dd($user)` in production | `Log::info()` structured |
|
|
427
|
+
| Generic error messages with stack traces | Clean error + request_id for debugging |
|
|
428
|
+
| Store API keys in plaintext | `'encrypted'` cast |
|
|
429
|
+
| Same rate limit for all endpoints | Progressive: auth(5/min) < api(60/min) |
|
|
430
|
+
| Trust `X-Forwarded-For` directly | Use trusted proxies config |
|
|
431
|
+
| No token expiry | 24h max, rotate on sensitive actions |
|