fix(auth): закрыть обход JWT через закодированные URL

This commit is contained in:
Denozordec committed 2026-10-13 21:23:51 +07:00
1 parent 727e35f985
commit 7d86af7e8b
19 files changed
+1656 -23

No files matched your search

+2 -8
View File
@@ -5,7 +5,7 @@ import { monitorEventLoopDelay } from "node:perf_hooks"
import { env } from "./config.js"
import { initDatabase } from "./db/bootstrap.js"
import { closePool } from "./db/index.js"
import authPlugin, { requireAuth } from "./plugins/auth.js"
import authPlugin, { requireApiAuth } from "./plugins/auth.js"
import { installDatabaseRequestGate } from "./plugins/database-request-gate.js"
import serversRoutes from "./routes/servers.js"
import bgpRoutes from "./routes/bgp.js"
@@ -108,13 +108,7 @@ export async function buildApp(opts?: {
}))
if (env.authRequired) {
app.addHook("preHandler", async (request, reply) => {
const pathname = request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/")) return
if (pathname === "/api/auth/config") return
await requireAuth(request, reply)
if (reply.sent) return
})
app.addHook("preHandler", requireApiAuth)
}
installDatabaseRequestGate(app)
+4 -4
View File
@@ -47,17 +47,17 @@ const RULES: Rule[] = [
{ methods: ["PUT", "POST"], match: path => path === "/api/geoip" || path === "/api/geoip/update", permission: "mm:traffic:write" },
{
methods: ["GET"],
match: p => /^\/api\/servers\/\d+\/ospf(?:\/|$)/.test(p),
match: p => /^\/api\/servers\/(?:\d+|:[A-Za-z]\w*)\/ospf(?:\/|$)/.test(p),
permission: "mm:network:read",
},
{
methods: ["POST"],
match: p => /^\/api\/servers\/\d+\/ospf\/optimize\/preview$/.test(p),
match: p => /^\/api\/servers\/(?:\d+|:[A-Za-z]\w*)\/ospf\/optimize\/preview$/.test(p),
permission: "mm:network:read",
},
{
methods: ["POST", "PUT", "PATCH", "DELETE"],
match: p => /^\/api\/servers\/\d+\/ospf(?:\/|$)/.test(p),
match: p => /^\/api\/servers\/(?:\d+|:[A-Za-z]\w*)\/ospf(?:\/|$)/.test(p),
permission: "mm:network:write",
},
@@ -201,7 +201,7 @@ export function permissionForRequest(
method: string,
path: string,
): string | null {
const m = method.toUpperCase()
const m = method.toUpperCase() === "HEAD" ? "GET" : method.toUpperCase()
const pathname = path.split("?")[0] ?? path
for (const rule of RULES) {
if (!rule.methods.includes(m)) continue
+16
View File
@@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import Fastify from 'fastify'
process.env.AUTH_REQUIRED='true';process.env.AUTH_JWT_SECRET='own211-synthetic-secret';process.env.AUTH_ISSUER='https://own211.invalid';process.env.NODE_ENV='test'
const {default:authPlugin,requireApiAuth}=await import('./auth.js')
const app=Fastify();await app.register(authPlugin);app.addHook('preHandler',requireApiAuth);let calls=0
for(const url of ['/api/servers','/api/gre/tunnels','/api/servers/:id/ospf'])app.get(url,async()=>{calls++;return{ok:true}})
app.patch('/api/gre/tunnels',async()=>{calls++;return{ok:true}})
app.post('/api/servers/:id/ospf/optimize/preview',async()=>{calls++;return{ok:true}})
app.get('/api/auth/config',async()=>({public:true}));app.get('/health',async()=>({public:true}));await app.ready()
const token=(permissions:string[])=>app.jwt.sign({sub:'own211',apps:['mm'],permissions,iss:process.env.AUTH_ISSUER},{expiresIn:'5m'})
let cases=0
for(const url of ['/api/servers','/api/%73ervers','/%61pi/servers','/%61%70%69/%73ervers'])for(const method of ['GET','HEAD'] as const){const before=calls,r=await app.inject({method,url});assert.equal(r.statusCode,401,url);assert.equal(calls,before);cases++}
for(const url of ['/api/gre/tunnels','/api/%67re/tunnels','/%61pi/gre/tunnels'])for(const method of ['GET','HEAD','PATCH'] as const)for(const role of ['dashboard','reader','writer']){const before=calls,permissions=role==='dashboard'?['mm:dashboard:read']:role==='reader'?['mm:network:read']:['mm:network:write'],allowed=role==='writer'||(role==='reader'&&method!=='PATCH'),r=await app.inject({method,url,headers:{authorization:'Bearer '+token(permissions)}});assert.equal(r.statusCode,allowed?200:403,url+' '+method+' '+role);assert.equal(calls,before+(allowed?1:0));cases++}
for(const method of ['GET','POST'] as const){const url=method==='GET'?'/api/servers/1/ospf':'/api/servers/1/ospf/optimize/preview';assert.equal((await app.inject({method,url,headers:{authorization:'Bearer '+token(['mm:network:read'])}})).statusCode,200);assert.equal((await app.inject({method,url,headers:{authorization:'Bearer '+token(['mm:servers:read'])}})).statusCode,403);cases+=2}
for(const url of ['/health','/api/auth/config','/%61pi/auth/config']){assert.equal((await app.inject({url})).statusCode,200);cases++}
await app.close();console.log('Canonical API auth: '+cases+' cases, denied handlers not called, HEAD and OSPF matched templates preserved')
+8 -1
View File
@@ -74,7 +74,7 @@ export async function requireAuth(
): Promise<void> {
if (!env.authRequired) return
const pathname = (request.url.split("?")[0] ?? request.url)
const pathname = request.routeOptions.url ?? request.url.split("?")[0] ?? request.url
if (pathname === "/api/auth/config") return
const authHeader = request.headers.authorization ?? ""
@@ -116,4 +116,11 @@ export async function requireAuth(
}
}
/** Gate the matched route, including encoded static segments in the raw URL. */
export async function requireApiAuth(request: FastifyRequest, reply: FastifyReply): Promise<void> {
const pathname = request.routeOptions.url ?? request.url.split("?")[0] ?? request.url
if (!pathname.startsWith("/api/") || pathname === "/api/auth/config") return
await requireAuth(request, reply)
}
export default fp(authPlugin, { name: "auth" })