start-vibing-stacks 2.17.0 → 2.19.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/package.json +1 -1
- package/stacks/frontend/react/skills/preline-ui/SKILL.md +6 -3
- package/stacks/frontend/react/skills/react-patterns/SKILL.md +125 -26
- package/stacks/frontend/react/skills/react-standards/SKILL.md +17 -4
- package/stacks/frontend/react/skills/react-ui-patterns/SKILL.md +106 -31
- package/stacks/frontend/react/skills/shadcn-ui/SKILL.md +284 -56
- package/stacks/frontend/react/skills/tailwind-patterns/SKILL.md +75 -16
- package/stacks/frontend/react/skills/zod-validation/SKILL.md +157 -35
- package/stacks/frontend/react-api/skills/axios-laravel-api/SKILL.md +2 -6
- package/stacks/frontend/react-api/skills/react-api-standards/SKILL.md +10 -12
- package/stacks/python/skills/api-security-python/SKILL.md +118 -15
- package/stacks/python/skills/async-patterns/SKILL.md +166 -62
- package/stacks/python/skills/django-patterns/SKILL.md +102 -11
- package/stacks/python/skills/fastapi-patterns/SKILL.md +277 -62
- package/stacks/python/skills/pydantic-validation/SKILL.md +106 -11
- package/stacks/python/skills/pytest-testing/SKILL.md +172 -54
- package/stacks/python/skills/python-patterns/SKILL.md +49 -7
- package/stacks/python/skills/python-performance/SKILL.md +183 -3
- package/stacks/python/skills/scripting-automation/SKILL.md +205 -119
|
@@ -1,42 +1,97 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: zod-validation
|
|
3
|
-
version:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Zod 4 (stable Aug 2025) runtime validation for TypeScript boundaries — forms, API responses, env vars, server actions. Zod 4 brings 14× faster string parsing, 7× array, 6.5× object, 57% smaller bundle, 2× faster TS compile, 100× fewer tsc instantiations. Major API shifts: top-level format validators (`z.email()`, `z.url()`, `z.uuid()`) for tree-shaking; unified `error` parameter replaces `required_error` / `invalid_type_error` / `errorMap`. Codemod available: `npx @zod/codemod`. Covers schema design, RHF + zodResolver integration, API response validation, split server/client env schemas (security-critical for `NEXT_PUBLIC_*`), reusable schemas, transforms, and Next.js Server Actions. Mentions Valibot/ArkType when bundle/inference cost matters. Invoke at any trust boundary."
|
|
4
5
|
---
|
|
5
6
|
|
|
6
|
-
# Zod
|
|
7
|
+
# Zod 4 — Runtime Type Safety (2026)
|
|
7
8
|
|
|
8
|
-
**ALWAYS use Zod for form validation, API responses,
|
|
9
|
+
**ALWAYS use Zod 4 for form validation, API responses, env vars, server actions.**
|
|
9
10
|
|
|
10
|
-
## Why Zod
|
|
11
|
+
## Why Zod 4 (stable since August 2025)
|
|
11
12
|
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
13
|
+
- **14× faster** string parsing, **7×** array, **6.5×** object
|
|
14
|
+
- **57% smaller** core bundle
|
|
15
|
+
- **~2× faster** TS compilation on large schemas; **100× fewer** tsc instantiations
|
|
16
|
+
- Ecosystem locked in: tRPC, Drizzle, React Hook Form, OpenAPI generators
|
|
17
|
+
- 15M+ weekly downloads — the de-facto standard
|
|
18
|
+
|
|
19
|
+
### When to consider an alternative
|
|
20
|
+
|
|
21
|
+
| Situation | Tool |
|
|
22
|
+
|---|---|
|
|
23
|
+
| **Bundle size** is critical (edge functions, embeds) | **Valibot** — sub-1KB simple schemas |
|
|
24
|
+
| You want **faster TS check** in giant codebases | **Valibot** (~18× faster type-check vs Zod 4) |
|
|
25
|
+
| You like TS-syntax string schemas | **ArkType** |
|
|
26
|
+
| Mainstream ecosystem + maturity | **Zod 4** ✅ |
|
|
27
|
+
|
|
28
|
+
## Migration v3 → v4
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npx @zod/codemod --transform v3-to-v4 ./src
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
The two breaking changes you'll see most:
|
|
35
|
+
|
|
36
|
+
### 1. Top-level format validators (tree-shaking)
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// v3 (works in v4 but not tree-shakable)
|
|
40
|
+
z.string().email().min(5);
|
|
41
|
+
|
|
42
|
+
// v4 — recommended
|
|
43
|
+
z.email();
|
|
44
|
+
z.url();
|
|
45
|
+
z.uuid();
|
|
46
|
+
z.iso.datetime(); // ISO-8601
|
|
47
|
+
z.iso.date();
|
|
48
|
+
z.cuid2();
|
|
49
|
+
z.ipv4();
|
|
50
|
+
z.ipv6();
|
|
51
|
+
z.base64();
|
|
52
|
+
z.jwt();
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
### 2. Unified `error` parameter
|
|
56
|
+
|
|
57
|
+
```tsx
|
|
58
|
+
// v3 — three different parameters
|
|
59
|
+
z.string({
|
|
60
|
+
required_error: "Required",
|
|
61
|
+
invalid_type_error: "Must be a string",
|
|
62
|
+
errorMap: ({ code }) => ({ message: code === "too_small" ? "Too short" : "Invalid" }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// v4 — one parameter, string or function
|
|
66
|
+
z.string({
|
|
67
|
+
error: (issue) => issue.code === "too_small" ? "Too short" : "Required",
|
|
68
|
+
});
|
|
69
|
+
// Or just a string:
|
|
70
|
+
z.string({ error: "Name is required" });
|
|
71
|
+
```
|
|
16
72
|
|
|
17
73
|
## Core Patterns
|
|
18
74
|
|
|
19
|
-
### Schema
|
|
75
|
+
### Schema definition
|
|
20
76
|
|
|
21
77
|
```tsx
|
|
22
78
|
import { z } from 'zod';
|
|
23
79
|
|
|
24
|
-
// Define schema
|
|
25
80
|
const UserSchema = z.object({
|
|
26
|
-
name:
|
|
27
|
-
email:
|
|
28
|
-
age:
|
|
29
|
-
role:
|
|
30
|
-
bio:
|
|
31
|
-
tags:
|
|
81
|
+
name: z.string().min(2, "Name must be at least 2 characters"),
|
|
82
|
+
email: z.email({ error: "Enter a valid email" }), // v4 top-level
|
|
83
|
+
age: z.number().min(18, "Must be 18+").max(120),
|
|
84
|
+
role: z.enum(["admin", "user", "moderator"]),
|
|
85
|
+
bio: z.string().max(500).optional(),
|
|
86
|
+
tags: z.array(z.string()).min(1, "At least one tag required"),
|
|
32
87
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
33
88
|
});
|
|
34
89
|
|
|
35
|
-
//
|
|
90
|
+
// SINGLE SOURCE OF TRUTH — schema → type
|
|
36
91
|
type User = z.infer<typeof UserSchema>;
|
|
37
92
|
```
|
|
38
93
|
|
|
39
|
-
### Form Validation (React Hook Form + Zod)
|
|
94
|
+
### Form Validation (React Hook Form + Zod 4)
|
|
40
95
|
|
|
41
96
|
```tsx
|
|
42
97
|
import { useForm } from 'react-hook-form';
|
|
@@ -44,7 +99,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
|
|
|
44
99
|
|
|
45
100
|
const CreateUserSchema = z.object({
|
|
46
101
|
name: z.string().min(2),
|
|
47
|
-
email: z.
|
|
102
|
+
email: z.email(), // v4 top-level
|
|
48
103
|
password: z.string().min(8, 'Password must be at least 8 characters'),
|
|
49
104
|
confirmPassword: z.string(),
|
|
50
105
|
}).refine((data) => data.password === data.confirmPassword, {
|
|
@@ -150,24 +205,47 @@ export const clientEnv = ClientEnvSchema.parse({
|
|
|
150
205
|
|
|
151
206
|
**Rule:** If a variable contains a key, secret, token, or password, it MUST be in `ServerEnvSchema` without `NEXT_PUBLIC_` prefix.
|
|
152
207
|
|
|
153
|
-
### Reusable Schemas
|
|
208
|
+
### Reusable Schemas (Zod 4 top-level)
|
|
154
209
|
|
|
155
210
|
```tsx
|
|
156
|
-
//
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
export const EmailSchema = z.string().email().toLowerCase().trim();
|
|
211
|
+
// schemas/common.ts — shared between frontend and backend
|
|
212
|
+
export const EmailSchema = z.email().transform(v => v.toLowerCase().trim());
|
|
160
213
|
export const PasswordSchema = z.string().min(8).max(128);
|
|
161
|
-
export const UUIDSchema
|
|
214
|
+
export const UUIDSchema = z.uuid();
|
|
215
|
+
export const URLSchema = z.url();
|
|
216
|
+
export const ISODateSchema = z.iso.datetime();
|
|
217
|
+
|
|
162
218
|
export const PaginationSchema = z.object({
|
|
163
|
-
page:
|
|
219
|
+
page: z.coerce.number().min(1).default(1),
|
|
164
220
|
limit: z.coerce.number().min(1).max(100).default(20),
|
|
165
221
|
});
|
|
166
222
|
|
|
167
223
|
export const DateRangeSchema = z.object({
|
|
168
224
|
from: z.coerce.date(),
|
|
169
|
-
to:
|
|
170
|
-
}).refine(
|
|
225
|
+
to: z.coerce.date(),
|
|
226
|
+
}).refine(d => d.to > d.from, "End date must be after start date");
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
### Discriminated unions (polymorphic payloads)
|
|
230
|
+
|
|
231
|
+
```tsx
|
|
232
|
+
const PaymentSchema = z.discriminatedUnion("type", [
|
|
233
|
+
z.object({ type: z.literal("card"), last4: z.string().length(4) }),
|
|
234
|
+
z.object({ type: z.literal("pix"), key: z.string() }),
|
|
235
|
+
z.object({ type: z.literal("boleto"), barcode: z.string() }),
|
|
236
|
+
]);
|
|
237
|
+
type Payment = z.infer<typeof PaymentSchema>;
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
### Branded types — distinguish IDs at the type level
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
const UserIdSchema = z.uuid().brand<"UserId">();
|
|
244
|
+
type UserId = z.infer<typeof UserIdSchema>;
|
|
245
|
+
|
|
246
|
+
function getUser(id: UserId) { /* … */ }
|
|
247
|
+
getUser("not a uuid"); // ❌ type error
|
|
248
|
+
getUser(UserIdSchema.parse(input)); // ✅
|
|
171
249
|
```
|
|
172
250
|
|
|
173
251
|
### Transform & Preprocess
|
|
@@ -253,20 +331,64 @@ export function LeadForm() {
|
|
|
253
331
|
}
|
|
254
332
|
```
|
|
255
333
|
|
|
334
|
+
## Use with React 19 Actions + `useActionState`
|
|
335
|
+
|
|
336
|
+
```tsx
|
|
337
|
+
"use client";
|
|
338
|
+
import { useActionState } from "react";
|
|
339
|
+
import { z } from "zod";
|
|
340
|
+
|
|
341
|
+
const Schema = z.object({
|
|
342
|
+
email: z.email(),
|
|
343
|
+
password: z.string().min(8),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
async function loginAction(_prev: { error: string | null }, formData: FormData) {
|
|
347
|
+
const parsed = Schema.safeParse(Object.fromEntries(formData));
|
|
348
|
+
if (!parsed.success) return { error: parsed.error.issues[0].message };
|
|
349
|
+
return await loginRequest(parsed.data);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export function LoginForm() {
|
|
353
|
+
const [state, action, pending] = useActionState(loginAction, { error: null });
|
|
354
|
+
return (
|
|
355
|
+
<form action={action}>
|
|
356
|
+
<input name="email" type="email" />
|
|
357
|
+
<input name="password" type="password" />
|
|
358
|
+
{state.error && <p className="text-destructive">{state.error}</p>}
|
|
359
|
+
<button disabled={pending}>{pending ? "Signing in…" : "Sign in"}</button>
|
|
360
|
+
</form>
|
|
361
|
+
);
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
256
365
|
## FORBIDDEN
|
|
257
366
|
|
|
258
367
|
| Don't | Do |
|
|
259
368
|
|---|---|
|
|
260
|
-
| Trust API responses blindly | `Schema.parse(response)` |
|
|
261
|
-
| Manual `if/else` validation | Zod schema |
|
|
369
|
+
| Trust API responses blindly | `Schema.parse(response)` (or `safeParse` for soft fail) |
|
|
370
|
+
| Manual `if/else` validation | Zod schema (single source of truth) |
|
|
262
371
|
| Duplicate types + validation | `z.infer<typeof Schema>` |
|
|
263
372
|
| `any` or `unknown` without validation | Parse with Zod first |
|
|
264
|
-
| Validate only on server | Validate on BOTH client
|
|
373
|
+
| Validate only on server | Validate on BOTH client and server |
|
|
374
|
+
| `z.string().email()` in v4 | `z.email()` (tree-shakable, top-level) |
|
|
375
|
+
| `required_error` / `invalid_type_error` / `errorMap` (v3) | Unified `error` parameter (v4) |
|
|
376
|
+
| Dump secrets into `NEXT_PUBLIC_*` | Server-only `ServerEnvSchema`; client gets only public keys |
|
|
377
|
+
| Re-implement common formats (URL, UUID, datetime, JWT) | `z.url()`, `z.uuid()`, `z.iso.datetime()`, `z.jwt()` |
|
|
265
378
|
|
|
266
379
|
## Rules
|
|
267
380
|
|
|
268
381
|
1. **SINGLE SOURCE OF TRUTH** — schema defines type AND validation
|
|
269
|
-
2. **VALIDATE AT BOUNDARIES** — forms, API responses, env vars
|
|
270
|
-
3. **SAFE PARSE FOR UI** — `safeParse()` for user-facing, `parse()` for internal
|
|
271
|
-
4. **SHARED SCHEMAS** — same schema on frontend and backend
|
|
272
|
-
5. **TRANSFORM
|
|
382
|
+
2. **VALIDATE AT BOUNDARIES** — forms, API responses, env vars, queue messages
|
|
383
|
+
3. **SAFE PARSE FOR UI** — `safeParse()` for user-facing forms, `parse()` for internal/trusted
|
|
384
|
+
4. **SHARED SCHEMAS** — same schema on frontend and backend (workspace package)
|
|
385
|
+
5. **TRANSFORM DURING VALIDATION** — `.toLowerCase()`, `.trim()`, `.transform(...)` happen as part of `parse`, not afterwards
|
|
386
|
+
6. **PREFER TOP-LEVEL FORMAT VALIDATORS** in v4 — better tree-shaking + clearer intent
|
|
387
|
+
|
|
388
|
+
## See Also
|
|
389
|
+
|
|
390
|
+
- `react-patterns` v2 — `useActionState`, `useOptimistic`, `use()` hook
|
|
391
|
+
- `react-ui-patterns` v2 — RHF + Zod + TanStack Query patterns
|
|
392
|
+
- `shadcn-ui` v2 — `<Form>` component built on RHF + Zod
|
|
393
|
+
- `_shared/skills/security-baseline` v2 — input validation as defense layer
|
|
394
|
+
- `_shared/skills/openapi-design` v2 — schemas → OpenAPI 3.2
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: axios-laravel-api
|
|
3
|
-
version:
|
|
4
|
-
description: Axios HTTP client for Laravel 12 + Sanctum SPA
|
|
5
|
-
`withXSRFToken`, `/sanctum/csrf-cookie` flow, and `401/403/419/422/5xx`
|
|
6
|
-
interceptors. Use when wiring a React (or any SPA) frontend to a Laravel
|
|
7
|
-
cookie-authenticated JSON API. Pairs with `laravel-api-architecture` and
|
|
8
|
-
`react-api-standards`.
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Axios HTTP client for Laravel 12 + Sanctum SPA in React 19 (Tailwind v4) projects. `withCredentials: true` + `withXSRFToken: true` + `/sanctum/csrf-cookie` priming flow, plus a single response interceptor that handles 401 (logout), 403 (forbidden toast), 419 (CSRF stale → re-prime + retry once), 422 (validation errors surfaced to RHF), 5xx (toast + log). Pairs with `laravel-api-architecture` (backend) and `react-api-standards` (frontend conventions). 2026 polish: Zod 4 response validation at the boundary, TanStack Query v5 `signal` threading for cancellation, React 19 forms via `useActionState` for client-side mutations. Invoke when wiring or auditing the API client, login flow, or CSRF/cookie configuration."
|
|
9
5
|
---
|
|
10
6
|
|
|
11
7
|
# Axios + Laravel Sanctum SPA Client
|
|
@@ -1,25 +1,23 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: react-api-standards
|
|
3
|
-
version:
|
|
4
|
-
description: React 19 + Vite + Axios standards for a Laravel-backed JSON API SPA
|
|
5
|
-
— Page shell + skeleton, async data via `api.get`, form 422 binding, route
|
|
6
|
-
catch-all, no Inertia. Use for any new React page in a Laravel + Sanctum SPA
|
|
7
|
-
project. Pairs with `axios-laravel-api` and `laravel-api-architecture`.
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "React 19 + Vite 6 + Axios standards for a Laravel-backed JSON API SPA. Page shell + skeleton-first paint, async data via `api.get`, 422 validation binding to React Hook Form, protected-route + catch-all routing, NO Inertia. Pairs with `axios-laravel-api` (HTTP client) and `laravel-api-architecture` (backend). 2026 polish: TanStack Query v5 (`isPending` semantics + `signal` threading), Zod 4 (`z.email()`/`z.url()` top-level), `useOptimistic` for instant feedback, `useActionState` for form submissions, `useFormStatus` to wire Submit buttons without prop drilling. Tailwind v4 with `@theme` and OKLCH tokens. Invoke for any new page, layout, or component in a Laravel + Sanctum SPA."
|
|
8
5
|
---
|
|
9
6
|
|
|
10
|
-
# React 19 API-First Standards (Laravel + Axios)
|
|
7
|
+
# React 19 API-First Standards (Laravel + Axios) — 2026
|
|
11
8
|
|
|
12
9
|
**ALWAYS invoke when creating React pages, components, or layouts in a project
|
|
13
10
|
that uses a Laravel JSON API as backend (NOT Inertia.js).**
|
|
14
11
|
|
|
15
12
|
## Version Requirements
|
|
16
13
|
|
|
17
|
-
- **React
|
|
18
|
-
- **Vite
|
|
19
|
-
- **TailwindCSS
|
|
20
|
-
- **Axios
|
|
21
|
-
- **TanStack Query
|
|
22
|
-
- **
|
|
14
|
+
- **React ≥ 19.0** — MANDATORY (`useActionState`, `useOptimistic`, `useFormStatus`, `use()`)
|
|
15
|
+
- **Vite ≥ 6** with `@vitejs/plugin-react`
|
|
16
|
+
- **TailwindCSS ≥ 4** (CSS-first `@theme`, Oxide engine)
|
|
17
|
+
- **Axios ≥ 1.7** (for `withXSRFToken` and modern interceptor types)
|
|
18
|
+
- **TanStack Query ≥ 5** — `isPending` (no data yet) vs `isFetching` (any in-flight); thread `signal` through `queryFn`
|
|
19
|
+
- **Zod ≥ 4** — top-level `z.email()`/`z.url()` for tree-shaking
|
|
20
|
+
- **react-router-dom ≥ 7** (`<RouterProvider>` + data routers)
|
|
23
21
|
|
|
24
22
|
## Folder Structure
|
|
25
23
|
|
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: api-security-python
|
|
3
|
-
version:
|
|
4
|
-
description:
|
|
3
|
+
version: 2.0.0
|
|
4
|
+
description: "Python (FastAPI / Django / Flask) overlay on top of _shared/security-baseline v2 (OWASP Top 10:2025). Production-grade hardening: security headers via Starlette middleware and SECURE_* settings, strict CORS allowlist, rate limiting (slowapi / django-ratelimit), HttpOnly+Secure+SameSite cookies, JWT with algorithms=[ALG] pinning + jti for revocation using PyJWT (python-jose is unmaintained as of 2025), CSRF built-in for Django and double-submit cookie for FastAPI, Pydantic V2 extra='forbid' against mass-assignment, file-upload magic-byte sniffing, Argon2id passwords, parameterised SQL/ORM. Cross-references 2025-A03 (Software Supply Chain Failures) and 2025-A10 (Mishandling Exceptional Conditions)."
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
-
# API Security — Python
|
|
7
|
+
# API Security — Python (FastAPI / Django / Flask)
|
|
8
8
|
|
|
9
9
|
**ALWAYS invoke when building API endpoints, auth flows, or admin actions.**
|
|
10
10
|
|
|
11
|
-
>
|
|
11
|
+
> Stack-specific overlay on top of `_shared/skills/security-baseline` v2 (OWASP Top 10:2025). The shared skill defines §A01–§A10 anchors; this one wires the Python equivalents.
|
|
12
12
|
|
|
13
13
|
## Layered Defense
|
|
14
14
|
|
|
@@ -136,13 +136,16 @@ response.set_cookie(
|
|
|
136
136
|
|
|
137
137
|
## 5. JWT / OAuth2 — FastAPI
|
|
138
138
|
|
|
139
|
+
> **Library choice (2026):** Use **PyJWT** (`pyjwt[crypto]`) or **authlib** for OAuth2/OIDC flows. `python-jose` has been effectively unmaintained since 2024 and should be removed from dependencies. For the resource-server side that just verifies tokens, PyJWT is enough.
|
|
140
|
+
|
|
139
141
|
```python
|
|
140
142
|
from datetime import datetime, timedelta, timezone
|
|
141
|
-
from jose import jwt, JWTError
|
|
142
143
|
import os, uuid
|
|
144
|
+
import jwt
|
|
145
|
+
from jwt import InvalidTokenError
|
|
143
146
|
|
|
144
147
|
SECRET = os.environ["JWT_SECRET"]
|
|
145
|
-
ALG = "HS256"
|
|
148
|
+
ALG = "HS256" # use RS256/EdDSA when verifying tokens minted elsewhere
|
|
146
149
|
|
|
147
150
|
def issue_access_token(user_id: str, role: str) -> str:
|
|
148
151
|
now = datetime.now(timezone.utc)
|
|
@@ -151,8 +154,11 @@ def issue_access_token(user_id: str, role: str) -> str:
|
|
|
151
154
|
"sub": user_id,
|
|
152
155
|
"role": role,
|
|
153
156
|
"iat": now,
|
|
157
|
+
"nbf": now,
|
|
154
158
|
"exp": now + timedelta(minutes=15),
|
|
155
159
|
"jti": str(uuid.uuid4()),
|
|
160
|
+
"iss": os.environ["JWT_ISSUER"],
|
|
161
|
+
"aud": os.environ["JWT_AUDIENCE"],
|
|
156
162
|
},
|
|
157
163
|
SECRET,
|
|
158
164
|
algorithm=ALG,
|
|
@@ -160,9 +166,18 @@ def issue_access_token(user_id: str, role: str) -> str:
|
|
|
160
166
|
|
|
161
167
|
async def current_user(token: str = Depends(oauth2_scheme)) -> User:
|
|
162
168
|
try:
|
|
163
|
-
payload = jwt.decode(
|
|
164
|
-
|
|
169
|
+
payload = jwt.decode(
|
|
170
|
+
token,
|
|
171
|
+
SECRET,
|
|
172
|
+
algorithms=[ALG], # pin — never accept "alg: none"
|
|
173
|
+
audience=os.environ["JWT_AUDIENCE"],
|
|
174
|
+
issuer=os.environ["JWT_ISSUER"],
|
|
175
|
+
options={"require": ["exp", "iat", "sub", "jti"]},
|
|
176
|
+
)
|
|
177
|
+
except InvalidTokenError:
|
|
165
178
|
raise HTTPException(401, "Invalid token")
|
|
179
|
+
if await is_revoked(payload["jti"]): # check revocation list (Redis set)
|
|
180
|
+
raise HTTPException(401, "Token revoked")
|
|
166
181
|
user = await User.get_or_none(id=payload["sub"])
|
|
167
182
|
if not user:
|
|
168
183
|
raise HTTPException(401, "User not found")
|
|
@@ -170,9 +185,11 @@ async def current_user(token: str = Depends(oauth2_scheme)) -> User:
|
|
|
170
185
|
```
|
|
171
186
|
|
|
172
187
|
Rules:
|
|
173
|
-
- Access tokens
|
|
174
|
-
- Pin `algorithms=[ALG]`. Never accept `alg: none
|
|
175
|
-
-
|
|
188
|
+
- Access tokens ≤ 15 min. Refresh tokens: rotate on use, store **hash** in DB, revocable.
|
|
189
|
+
- Pin `algorithms=[ALG]`. Never accept `alg: none` or omit the kwarg (confusion attack).
|
|
190
|
+
- Always validate `aud` and `iss` when present.
|
|
191
|
+
- Include `jti` and check a revocation set for sensitive scopes.
|
|
192
|
+
- Store JWTs in **HttpOnly+Secure+SameSite cookies**, not `localStorage` (XSS exfiltration).
|
|
176
193
|
|
|
177
194
|
---
|
|
178
195
|
|
|
@@ -280,6 +297,91 @@ user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Dj
|
|
|
280
297
|
|
|
281
298
|
---
|
|
282
299
|
|
|
300
|
+
## 11. Outbound calls — SSRF guard (OWASP 2025 still relevant)
|
|
301
|
+
|
|
302
|
+
SSRF was demoted from a top-level OWASP category in the 2025 list but remains in scope. Any time you fetch a URL the user controls (preview cards, webhooks, image proxies), validate the destination:
|
|
303
|
+
|
|
304
|
+
```python
|
|
305
|
+
import ipaddress
|
|
306
|
+
import socket
|
|
307
|
+
from urllib.parse import urlparse
|
|
308
|
+
import httpx
|
|
309
|
+
|
|
310
|
+
PRIVATE = ipaddress.collapse_addresses([
|
|
311
|
+
ipaddress.ip_network("10.0.0.0/8"),
|
|
312
|
+
ipaddress.ip_network("172.16.0.0/12"),
|
|
313
|
+
ipaddress.ip_network("192.168.0.0/16"),
|
|
314
|
+
ipaddress.ip_network("169.254.0.0/16"), # link-local + AWS metadata
|
|
315
|
+
ipaddress.ip_network("127.0.0.0/8"),
|
|
316
|
+
ipaddress.ip_network("::1/128"),
|
|
317
|
+
])
|
|
318
|
+
|
|
319
|
+
def safe_url(url: str) -> str:
|
|
320
|
+
p = urlparse(url)
|
|
321
|
+
if p.scheme not in {"http", "https"}:
|
|
322
|
+
raise ValueError("scheme")
|
|
323
|
+
host = p.hostname
|
|
324
|
+
if not host:
|
|
325
|
+
raise ValueError("host")
|
|
326
|
+
for fam, _, _, _, sockaddr in socket.getaddrinfo(host, None):
|
|
327
|
+
ip = ipaddress.ip_address(sockaddr[0])
|
|
328
|
+
if any(ip in net for net in PRIVATE):
|
|
329
|
+
raise ValueError("private")
|
|
330
|
+
return url
|
|
331
|
+
|
|
332
|
+
async def fetch_user_url(url: str):
|
|
333
|
+
safe_url(url)
|
|
334
|
+
async with httpx.AsyncClient(timeout=10.0, follow_redirects=False) as c:
|
|
335
|
+
return await c.get(url)
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
Disable redirect-following or re-validate every hop — otherwise an attacker can redirect from a public IP to `169.254.169.254` (cloud metadata).
|
|
339
|
+
|
|
340
|
+
## 12. OWASP 2025 deltas — Python specifics
|
|
341
|
+
|
|
342
|
+
### §A03 — Software Supply Chain Failures (NEW in 2025)
|
|
343
|
+
|
|
344
|
+
```toml
|
|
345
|
+
# pyproject.toml — pin everything; uv produces a deterministic lockfile
|
|
346
|
+
[project]
|
|
347
|
+
dependencies = ["fastapi>=0.115,<0.116", "pydantic>=2.6,<3"]
|
|
348
|
+
|
|
349
|
+
[tool.uv]
|
|
350
|
+
dev-dependencies = ["pip-audit>=2.7", "ruff>=0.5"]
|
|
351
|
+
```
|
|
352
|
+
|
|
353
|
+
```bash
|
|
354
|
+
# Lock + verify in CI
|
|
355
|
+
uv lock --check # fails if lockfile drifted
|
|
356
|
+
uv export --format requirements-txt --no-dev | pip-audit -r /dev/stdin --strict
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
- Use `uv` (or Poetry) to produce a deterministic lockfile; never `pip install` without one in production.
|
|
360
|
+
- Run `pip-audit` (PyPA) **and** subscribe to GHSA advisories for your deps.
|
|
361
|
+
- Pin GitHub Actions by SHA, not tag — see `_shared/skills/secrets-management`.
|
|
362
|
+
|
|
363
|
+
### §A10 — Mishandling Exceptional Conditions (NEW in 2025)
|
|
364
|
+
|
|
365
|
+
```python
|
|
366
|
+
# WRONG — silently swallows everything, including programming bugs
|
|
367
|
+
try:
|
|
368
|
+
user = await get_user(id)
|
|
369
|
+
except Exception:
|
|
370
|
+
user = None
|
|
371
|
+
|
|
372
|
+
# CORRECT — narrow except + log + propagate or translate
|
|
373
|
+
try:
|
|
374
|
+
user = await get_user(id)
|
|
375
|
+
except UserNotFoundError:
|
|
376
|
+
raise HTTPException(404, "user not found")
|
|
377
|
+
except DatabaseUnavailableError as e:
|
|
378
|
+
logger.exception("db-down", extra={"user_id": id})
|
|
379
|
+
raise HTTPException(503, "service unavailable") from e
|
|
380
|
+
# Programming errors (KeyError, TypeError) are NOT caught — let the global handler 500 + log
|
|
381
|
+
```
|
|
382
|
+
|
|
383
|
+
Bare `except Exception:` (or worse, `except:`) is now an explicit OWASP pattern to flag. Use `logger.exception()` so the stack trace lands in logs, return a **generic** message to the client, never the original exception text.
|
|
384
|
+
|
|
283
385
|
## Endpoint Checklist
|
|
284
386
|
|
|
285
387
|
- [ ] `Depends(current_user)` for protected routes
|
|
@@ -306,7 +408,8 @@ user = await User.get(id=user_id) # Tortoise / SQLAlchemy / Dj
|
|
|
306
408
|
|
|
307
409
|
## See Also
|
|
308
410
|
|
|
309
|
-
- `security-baseline` — OWASP Top 10
|
|
310
|
-
- `secrets-management` —
|
|
311
|
-
- `
|
|
312
|
-
- `
|
|
411
|
+
- `_shared/skills/security-baseline` v2 — OWASP Top 10:2025 (§A01–§A10 anchors)
|
|
412
|
+
- `_shared/skills/secrets-management` v2 — OIDC federation, gitleaks 3-layer, SOPS+age
|
|
413
|
+
- `_shared/skills/observability` v2 — structured logs without PII, GenAI semconv 1.41+
|
|
414
|
+
- `pydantic-validation` v2 — `extra="forbid"` against mass-assignment
|
|
415
|
+
- `fastapi-patterns` v2 — lifespan, DI, exception handlers
|