Skip to content
Merged
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
17 changes: 17 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
@@ -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.
16 changes: 14 additions & 2 deletions backend/src/middleware/securityHeaders.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,33 @@
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'";

/**
* Cabeçalhos de segurança aplicados a todas as respostas.
*
* A API usa uma CSP sem fontes de recursos; a documentação em `/docs` usa
* uma política limitada aos assets locais do Swagger.
*
* `Strict-Transport-Security` só é enviado sobre HTTPS (atrás do proxy,
* `req.secure` reflete `x-forwarded-proto` graças a `app.set("trust proxy", 1)`)
* ou quando `NODE_ENV=production`, onde o tráfego é sempre HTTPS. `preload`
* fica de fora de propósito: entrar na lista de preload é uma decisão difícil
* de reverter e deve ser um opt-in explícito do time.
*
* CSP não é definida aqui — é tratada na PAV-132.
*/
export function securityHeaders(
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");
Expand Down
40 changes: 40 additions & 0 deletions backend/tests/unit/middleware/securityHeaders.test.ts
Original file line number Diff line number Diff line change
@@ -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'");
});
});
7 changes: 6 additions & 1 deletion front_admin/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
30 changes: 1 addition & 29 deletions frontend/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,35 +5,7 @@
<link rel="icon" type="image/png" href="public/icone.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />

<script>
(function () {
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;

if (resolvedTheme === "dark") {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}

root.setAttribute("data-theme", resolvedTheme);
})();
</script>
<script type="module" src="/src/theme-bootstrap.ts"></script>

<title><Cand!Date!></title>
</head>
Expand Down
7 changes: 6 additions & 1 deletion frontend/nginx.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
21 changes: 21 additions & 0 deletions frontend/src/theme-bootstrap.ts
Original file line number Diff line number Diff line change
@@ -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);
16 changes: 16 additions & 0 deletions frontend/tests/unit/new_dashboard/jobs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,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(
<FormattedJobDescription
description={[
'<a href="data:text/html,blocked">Link de dados</a>',
'<p onclick="alert(1)">Texto seguro</p>',
'<iframe src="https://example.com"></iframe>',
].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();
Expand Down
14 changes: 14 additions & 0 deletions frontend/vercel.json
Original file line number Diff line number Diff line change
@@ -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/(.*)",
Expand Down
14 changes: 14 additions & 0 deletions vercel.json
Original file line number Diff line number Diff line change
@@ -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/(.*)",
Expand Down
Loading