Skip to content
Open
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
7 changes: 6 additions & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,11 @@ USDC_ISSUER_ADDRESS=GBBD47IF6LWK7P7MDEVSCWR7DPUWV3NY3DTQEVFL4NAT4AQH3ZLLFLA5
# ============================================
# Frontend / API Config
# ============================================
# Allowed Origins for CORS policy (comma-separated list)
# e.g. https://app.remitwise.com, http://localhost:3000
# Do not use * in production if credentials are required.
ALLOWED_ORIGINS=http://localhost:3000

# Frontend application URL (fallback if ALLOWED_ORIGINS is not set)
NEXT_PUBLIC_APP_URL=http://localhost:3000
API_MAX_BODY_SIZE=1048576

11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,18 +261,21 @@ The middleware (`middleware.ts`) applies CORS headers, security headers, and req

Cross-Origin Resource Sharing (CORS) is configured to allow requests from the frontend application:

- **Allowed Origins**: Requests from `NEXT_PUBLIC_APP_URL` are allowed (or same-origin)
- **Allowed Origins**: Configured via the `ALLOWED_ORIGINS` environment variable (comma-separated list, e.g., `https://app.remitwise.com, http://localhost:3000`). If not set, it falls back to `NEXT_PUBLIC_APP_URL`.
- **Allowed Methods**: GET, POST, PUT, DELETE, PATCH, OPTIONS
- **Allowed Headers**: Content-Type, Authorization, X-Requested-With
- **Credentials**: Allowed for same-origin requests
- **Credentials**: Allowed for specific origins. **Note:** Do not use `*` for origins in production if credentials are required, as browsers block credentials when `Access-Control-Allow-Origin: *` is set. If `*` is used, credentials will not be allowed.
- **Preflight Handling**: OPTIONS requests return 204 No Content with appropriate CORS headers

**Configuration:**

Set `NEXT_PUBLIC_APP_URL` in `.env.local`:
Set `ALLOWED_ORIGINS` in `.env.local` to specify authorized clients:

```bash
# Frontend URL for CORS policy (e.g., http://localhost:3000)
# Comma-separated list of allowed origins
ALLOWED_ORIGINS=https://app.remitwise.com,http://localhost:3000

# Frontend URL (fallback if ALLOWED_ORIGINS is not set)
NEXT_PUBLIC_APP_URL=http://localhost:3000
```

Expand Down
33 changes: 24 additions & 9 deletions middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,17 +28,32 @@ const SECURITY_HEADERS: Record<string, string> = {

// Helper functions
function applyCORS(response: NextResponse, request: NextRequest): void {
const allowedOrigin =
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const allowedOriginsStr =
process.env.ALLOWED_ORIGINS ||
process.env.NEXT_PUBLIC_APP_URL ||
"http://localhost:3000";

const allowedOrigins = allowedOriginsStr
.split(",")
.map((o) => o.trim())
.filter(Boolean);

const requestOrigin = request.headers.get("origin");
const isSameOrigin = !requestOrigin || requestOrigin === allowedOrigin;
if (isSameOrigin || requestOrigin === allowedOrigin) {
response.headers.set(
"Access-Control-Allow-Origin",
requestOrigin || allowedOrigin,
);

if (requestOrigin) {
if (
allowedOrigins.includes(requestOrigin) ||
allowedOrigins.includes("*")
) {
const originToSet = allowedOrigins.includes("*") ? "*" : requestOrigin;
response.headers.set("Access-Control-Allow-Origin", originToSet);

if (originToSet !== "*") {
response.headers.set("Access-Control-Allow-Credentials", "true");
}
}
}
response.headers.set("Access-Control-Allow-Credentials", "true");

response.headers.set(
"Access-Control-Allow-Methods",
CORS_ALLOWED_METHODS.join(", "),
Expand Down
146 changes: 100 additions & 46 deletions tests/unit/middleware.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const assert = require("node:assert/strict");
*/

// Mock NextRequest and NextResponse for testing
class MockNextRequest {
class MockNextRequest {
constructor(method = "GET", headers = {}, body = null) {
this.method = method;
this.headers = new Map(Object.entries(headers));
Expand Down Expand Up @@ -60,26 +60,39 @@ test("applyCORS: Sets correct CORS headers for same-origin request", (t) => {

// Mock applyCORS function
const applyCORS = (response, request) => {
const allowedOrigin =
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const allowedOriginsStr =
process.env.ALLOWED_ORIGINS ||
process.env.NEXT_PUBLIC_APP_URL ||
"http://localhost:3000";

const allowedOrigins = allowedOriginsStr
.split(",")
.map((o) => o.trim())
.filter(Boolean);

const requestOrigin = request.headers.get("origin");
const isSameOrigin = !requestOrigin || requestOrigin === allowedOrigin;

if (isSameOrigin || requestOrigin === allowedOrigin) {
response.set(
"Access-Control-Allow-Origin",
requestOrigin || allowedOrigin,
);
if (requestOrigin) {
if (
allowedOrigins.includes(requestOrigin) ||
allowedOrigins.includes("*")
) {
const originToSet = allowedOrigins.includes("*") ? "*" : requestOrigin;
response.set("Access-Control-Allow-Origin", originToSet);

if (originToSet !== "*") {
response.set("Access-Control-Allow-Credentials", "true");
}
}
}

response.set("Access-Control-Allow-Credentials", "true");
response.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With",
"Content-Type, Authorization, X-Requested-With"
);
response.set("Vary", "Origin");
};
Expand Down Expand Up @@ -113,26 +126,39 @@ test("applyCORS: Sets CORS headers for request from allowed origin", (t) => {
const response = MockNextResponse.next();

const applyCORS = (response, request) => {
const allowedOrigin =
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const allowedOriginsStr =
process.env.ALLOWED_ORIGINS ||
process.env.NEXT_PUBLIC_APP_URL ||
"http://localhost:3000";

const allowedOrigins = allowedOriginsStr
.split(",")
.map((o) => o.trim())
.filter(Boolean);

const requestOrigin = request.headers.get("origin");
const isSameOrigin = !requestOrigin || requestOrigin === allowedOrigin;

if (isSameOrigin || requestOrigin === allowedOrigin) {
response.set(
"Access-Control-Allow-Origin",
requestOrigin || allowedOrigin,
);
if (requestOrigin) {
if (
allowedOrigins.includes(requestOrigin) ||
allowedOrigins.includes("*")
) {
const originToSet = allowedOrigins.includes("*") ? "*" : requestOrigin;
response.set("Access-Control-Allow-Origin", originToSet);

if (originToSet !== "*") {
response.set("Access-Control-Allow-Credentials", "true");
}
}
}

response.set("Access-Control-Allow-Credentials", "true");
response.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With",
"Content-Type, Authorization, X-Requested-With"
);
response.set("Vary", "Origin");
};
Expand All @@ -152,31 +178,46 @@ test("applyCORS: Falls back to NEXT_PUBLIC_APP_URL when env var set", (t) => {
const originalEnv = process.env.NEXT_PUBLIC_APP_URL;
process.env.NEXT_PUBLIC_APP_URL = "http://localhost:3000";

const request = new MockNextRequest("GET", {});
const request = new MockNextRequest("GET", {
origin: "http://localhost:3000"
});

const response = MockNextResponse.next();

const applyCORS = (response, request) => {
const allowedOrigin =
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const allowedOriginsStr =
process.env.ALLOWED_ORIGINS ||
process.env.NEXT_PUBLIC_APP_URL ||
"http://localhost:3000";

const allowedOrigins = allowedOriginsStr
.split(",")
.map((o) => o.trim())
.filter(Boolean);

const requestOrigin = request.headers.get("origin");
const isSameOrigin = !requestOrigin || requestOrigin === allowedOrigin;

if (isSameOrigin || requestOrigin === allowedOrigin) {
response.set(
"Access-Control-Allow-Origin",
requestOrigin || allowedOrigin,
);
if (requestOrigin) {
if (
allowedOrigins.includes(requestOrigin) ||
allowedOrigins.includes("*")
) {
const originToSet = allowedOrigins.includes("*") ? "*" : requestOrigin;
response.set("Access-Control-Allow-Origin", originToSet);

if (originToSet !== "*") {
response.set("Access-Control-Allow-Credentials", "true");
}
}
}

response.set("Access-Control-Allow-Credentials", "true");
response.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With",
"Content-Type, Authorization, X-Requested-With"
);
response.set("Vary", "Origin");
};
Expand Down Expand Up @@ -460,26 +501,39 @@ test("Middleware: CORS applied before rate limiting", (t) => {

// CORS should be applied first
const applyCORS = (response, request) => {
const allowedOrigin =
process.env.NEXT_PUBLIC_APP_URL || "http://localhost:3000";
const allowedOriginsStr =
process.env.ALLOWED_ORIGINS ||
process.env.NEXT_PUBLIC_APP_URL ||
"http://localhost:3000";

const allowedOrigins = allowedOriginsStr
.split(",")
.map((o) => o.trim())
.filter(Boolean);

const requestOrigin = request.headers.get("origin");
const isSameOrigin = !requestOrigin || requestOrigin === allowedOrigin;

if (isSameOrigin || requestOrigin === allowedOrigin) {
response.set(
"Access-Control-Allow-Origin",
requestOrigin || allowedOrigin,
);
if (requestOrigin) {
if (
allowedOrigins.includes(requestOrigin) ||
allowedOrigins.includes("*")
) {
const originToSet = allowedOrigins.includes("*") ? "*" : requestOrigin;
response.set("Access-Control-Allow-Origin", originToSet);

if (originToSet !== "*") {
response.set("Access-Control-Allow-Credentials", "true");
}
}
}

response.set("Access-Control-Allow-Credentials", "true");
response.set(
"Access-Control-Allow-Methods",
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
"GET, POST, PUT, DELETE, PATCH, OPTIONS"
);
response.set(
"Access-Control-Allow-Headers",
"Content-Type, Authorization, X-Requested-With",
"Content-Type, Authorization, X-Requested-With"
);
response.set("Vary", "Origin");
};
Expand Down