diff --git a/README.md b/README.md index 1b81131..dddf9bb 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ [TESTING](TESTING.md) | [CONTRIBUTING](contribuition.md) | [ESCOPO](ESCOPO.md) | +[SECURITY](SECURITY.md) | [Frontend](frontend/README.md) | [Frontend Architecture](frontend/ARCHITECTURE.md) | [Front Admin](front_admin/README.md) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..7f8aa0a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Segurança + +## Content Security Policy + +Os frontends publicados por Nginx e Vercel enviam uma Content Security Policy (CSP) restritiva. A política bloqueia plugins, enquadramento por outros sites, scripts externos e execução de scripts inline. Estilos inline permanecem permitidos porque componentes React aplicam estilos dinâmicos; isso não autoriza JavaScript inline. + +As origens permitidas são mantidas explicitamente nos arquivos `frontend/nginx.conf`, `front_admin/nginx.conf`, `vercel.json` e `frontend/vercel.json`. Antes de incluir uma nova origem, confirme que ela é necessária e restrinja-a à diretiva correta (`connect-src`, `img-src`, `font-src` ou `style-src`). Não use curingas nem adicione `'unsafe-inline'` a `script-src`. + +A API responde com uma CSP ainda mais restritiva, apropriada para respostas JSON: não permite carregar recursos, executar scripts, enviar formulários ou ser incorporada em frames. + +## Conteúdo externo + +Descrições de vagas são convertidas em elementos React a partir de uma allowlist. Não use `dangerouslySetInnerHTML` para renderizar dados de vagas, perfis ou integrações externas. Links externos devem aceitar somente URLs `http` e `https`; protocolos executáveis e atributos de evento não devem ser propagados para o DOM. + +## Sessão + +Cookies de sessão são `HttpOnly` e usam `Secure` em produção, com `SameSite` definido. A CSP reduz o impacto de uma regressão de XSS, mas não substitui a validação e a renderização segura de dados. diff --git a/backend/src/middleware/securityHeaders.ts b/backend/src/middleware/securityHeaders.ts index b6b18a0..02b0491 100644 --- a/backend/src/middleware/securityHeaders.ts +++ b/backend/src/middleware/securityHeaders.ts @@ -1,10 +1,21 @@ import { NextFunction, Request, Response } from "express"; +export const apiContentSecurityPolicy = + "default-src 'none'; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'"; + +export const swaggerContentSecurityPolicy = + "default-src 'self'; base-uri 'none'; object-src 'none'; form-action 'none'; frame-ancestors 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'"; + export function securityHeaders( - _req: Request, + req: Request, res: Response, next: NextFunction, ): void { + const contentSecurityPolicy = req.path.startsWith("/docs") + ? swaggerContentSecurityPolicy + : apiContentSecurityPolicy; + + res.setHeader("Content-Security-Policy", contentSecurityPolicy); res.setHeader("X-Content-Type-Options", "nosniff"); res.setHeader("X-Frame-Options", "DENY"); res.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); diff --git a/backend/tests/unit/middleware/securityHeaders.test.ts b/backend/tests/unit/middleware/securityHeaders.test.ts new file mode 100644 index 0000000..8265512 --- /dev/null +++ b/backend/tests/unit/middleware/securityHeaders.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from "vitest"; +import { + apiContentSecurityPolicy, + securityHeaders, + swaggerContentSecurityPolicy, +} from "../../../src/middleware/securityHeaders"; + +describe("securityHeaders", () => { + it("aplica uma CSP restritiva e os demais headers de proteção", () => { + const setHeader = vi.fn(); + const next = vi.fn(); + + securityHeaders({ path: "/health" } as any, { setHeader } as any, next); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Security-Policy", + apiContentSecurityPolicy, + ); + expect(apiContentSecurityPolicy).toContain("default-src 'none'"); + expect(apiContentSecurityPolicy).toContain("object-src 'none'"); + expect(apiContentSecurityPolicy).toContain("frame-ancestors 'none'"); + expect(setHeader).toHaveBeenCalledWith("X-Content-Type-Options", "nosniff"); + expect(setHeader).toHaveBeenCalledWith("X-Frame-Options", "DENY"); + expect(next).toHaveBeenCalledOnce(); + }); + + it("mantem o Swagger funcional com uma CSP limitada a documentacao", () => { + const setHeader = vi.fn(); + const next = vi.fn(); + + securityHeaders({ path: "/docs" } as any, { setHeader } as any, next); + + expect(setHeader).toHaveBeenCalledWith( + "Content-Security-Policy", + swaggerContentSecurityPolicy, + ); + expect(swaggerContentSecurityPolicy).toContain("default-src 'self'"); + expect(swaggerContentSecurityPolicy).toContain("script-src 'self' 'unsafe-inline'"); + }); +}); diff --git a/front_admin/nginx.conf b/front_admin/nginx.conf index 67e4472..2712bce 100644 --- a/front_admin/nginx.conf +++ b/front_admin/nginx.conf @@ -4,7 +4,12 @@ server { root /usr/share/nginx/html; index index.html; + add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self' https://api.candidate.app.br https://jobsglobalscraper.ddns.net" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + location / { try_files $uri $uri/ /index.html; } -} \ No newline at end of file +} diff --git a/frontend/index.html b/frontend/index.html index 12ad152..eacb24c 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -5,35 +5,7 @@ - + <Cand!Date!> diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 67e4472..b2d1d9c 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -4,7 +4,12 @@ server { root /usr/share/nginx/html; index index.html; + add_header Content-Security-Policy "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.github.com https://api.candidate.app.br https://jobsglobalscraper.ddns.net" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-Frame-Options "DENY" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + location / { try_files $uri $uri/ /index.html; } -} \ No newline at end of file +} diff --git a/frontend/src/theme-bootstrap.ts b/frontend/src/theme-bootstrap.ts new file mode 100644 index 0000000..2636a7c --- /dev/null +++ b/frontend/src/theme-bootstrap.ts @@ -0,0 +1,21 @@ +const root = document.documentElement; +const storedPreference = + localStorage.getItem("jobs-theme-preference") ?? + localStorage.getItem("theme") ?? + localStorage.getItem("vite-ui-theme"); +const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; +const themePreference = + storedPreference === "light" || + storedPreference === "dark" || + storedPreference === "system" + ? storedPreference + : "system"; +const resolvedTheme = + themePreference === "system" + ? prefersDark + ? "dark" + : "light" + : themePreference; + +root.classList.toggle("dark", resolvedTheme === "dark"); +root.setAttribute("data-theme", resolvedTheme); diff --git a/frontend/tests/unit/new_dashboard/jobs.test.tsx b/frontend/tests/unit/new_dashboard/jobs.test.tsx index 6586c84..1841b3a 100644 --- a/frontend/tests/unit/new_dashboard/jobs.test.tsx +++ b/frontend/tests/unit/new_dashboard/jobs.test.tsx @@ -510,6 +510,22 @@ describe("new_dashboard job components", () => { expect(screen.getByText("Conteúdo preservado")).toBeInTheDocument(); }); + it("não propaga atributos ativos nem protocolos não permitidos", () => { + const { container } = render( + Link de dados', + '

Texto seguro

', + '', + ].join("")} + />, + ); + + expect(screen.getByText("Link de dados").tagName).toBe("SPAN"); + expect(screen.getByText("Texto seguro")).not.toHaveAttribute("onclick"); + expect(container.querySelector("iframe")).not.toBeInTheDocument(); + }); + it("valida e salva uma vaga manual nova", () => { const onAddJob = vi.fn(); const onClose = vi.fn(); diff --git a/frontend/vercel.json b/frontend/vercel.json index e06a47c..3a44677 100644 --- a/frontend/vercel.json +++ b/frontend/vercel.json @@ -1,4 +1,18 @@ { + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.github.com https://api.candidate.app.br https://jobsglobalscraper.ddns.net" + }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ], "rewrites": [ { "source": "/api/(.*)", diff --git a/vercel.json b/vercel.json index d45c354..cc4d6d6 100644 --- a/vercel.json +++ b/vercel.json @@ -1,4 +1,18 @@ { + "headers": [ + { + "source": "/(.*)", + "headers": [ + { + "key": "Content-Security-Policy", + "value": "default-src 'self'; base-uri 'none'; object-src 'none'; frame-ancestors 'none'; form-action 'self'; script-src 'self'; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' data: https://fonts.gstatic.com; img-src 'self' data: https:; connect-src 'self' https://api.github.com https://api.candidate.app.br https://jobsglobalscraper.ddns.net" + }, + { "key": "X-Content-Type-Options", "value": "nosniff" }, + { "key": "X-Frame-Options", "value": "DENY" }, + { "key": "Referrer-Policy", "value": "strict-origin-when-cross-origin" } + ] + } + ], "rewrites": [ { "source": "/api/(.*)",