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
4 changes: 3 additions & 1 deletion .dev.vars.example
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ DEBUG_MODEL=
REASONING_EFFORT=medium # Options: minimal, low, medium, high
REASONING_SUMMARY=auto # Options: auto, on, off
REASONING_COMPAT=think-tags # Options: think-tags, standard
VERBOSE=false # Options: true, false
VERBOSE=false # Options: true, false
# Optional CORS allowlist (comma-separated exact origins)
ALLOWED_ORIGINS=https://app.example.com
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,9 @@ REASONING_COMPAT=think-tags
# Optional: Debug settings
VERBOSE=false
DEBUG_MODEL=

# Optional: CORS allowlist (comma-separated exact origins)
ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com
```

For production, set the secrets:
Expand Down Expand Up @@ -243,6 +246,15 @@ The service will be available at `http://localhost:8787`
| `OLLAMA_API_URL` | `http://localhost:11434` | Ollama instance URL for local model integration |
| `DEBUG_MODEL` | - | Override model for debugging purposes |
| `VERBOSE` | `false` | Enable detailed debug logging |
| `ALLOWED_ORIGINS` | - | Comma-separated exact origins allowed for CORS (for example: `https://app.example.com,https://admin.example.com`) |


#### CORS Configuration

- CORS is deny-by-default unless an origin is listed in `ALLOWED_ORIGINS`.
- `Access-Control-Allow-Credentials: true` is only returned for explicitly allowlisted origins.
- Allowed methods are restricted to `POST`, `GET`, and `OPTIONS`.
- Allowed request headers are restricted to `Content-Type` and `Authorization`.

#### Authentication Security

Expand Down
64 changes: 50 additions & 14 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,57 @@
import { Hono } from "hono";
import { cors } from "hono/cors";
import openai from "./routes/openai"; // Import the openai router
import ollama from "./routes/ollama"; // Import the ollama router
import type { Env } from "./types";

const app = new Hono();

app.use(
"*",
cors({
origin: "*", // Or specify allowed origins
allowHeaders: ["Content-Type", "Authorization", "OpenAI-Beta", "chatgpt-account-id"],
allowMethods: ["POST", "GET", "OPTIONS"],
exposeHeaders: ["Content-Length", "X-Kuma-Revision"],
maxAge: 600,
credentials: true
})
);
const DEFAULT_ALLOWED_HEADERS = ["Content-Type", "Authorization"];
const DEFAULT_ALLOWED_METHODS = ["POST", "GET", "OPTIONS"];

const parseAllowedOrigins = (allowedOrigins?: string): string[] => {
if (!allowedOrigins) {
return [];
}

return allowedOrigins
.split(",")
.map((origin) => origin.trim())
.filter((origin) => origin.length > 0);
};

const resolveAllowedOrigin = (origin: string, allowedOrigins: string[]): string | null => {
if (!origin || allowedOrigins.length === 0) {
return null;
}

return allowedOrigins.includes(origin) ? origin : null;
};

const app = new Hono<{ Bindings: Env }>();

app.use("*", async (c, next) => {
const origin = c.req.header("origin") || "";
const allowedOrigin = resolveAllowedOrigin(origin, parseAllowedOrigins(c.env.ALLOWED_ORIGINS));

if (allowedOrigin) {
c.header("Access-Control-Allow-Origin", allowedOrigin);
c.header("Vary", "Origin");
}

if (c.req.method === "OPTIONS") {
if (allowedOrigin) {
c.header("Access-Control-Allow-Methods", DEFAULT_ALLOWED_METHODS.join(","));
c.header("Access-Control-Allow-Headers", DEFAULT_ALLOWED_HEADERS.join(","));
c.header("Access-Control-Max-Age", "600");
c.header("Access-Control-Allow-Credentials", "true");
}
return c.body(null, 204);
}

await next();

if (allowedOrigin) {
c.header("Access-Control-Allow-Credentials", "true");
}
});

app.get("/", (c) => c.json({ status: "ok" }));

Expand Down
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Env {
REASONING_SUMMARY?: ReasoningSummary;
REASONING_COMPAT?: ReasoningCompat;
VERBOSE?: VerboseMode;
ALLOWED_ORIGINS?: string;
}

export type AuthTokens = {
Expand Down
91 changes: 76 additions & 15 deletions test/index.spec.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,85 @@
import { env, createExecutionContext, waitOnExecutionContext, SELF } from 'cloudflare:test';
import { describe, it, expect } from 'vitest';
import worker from '../src/index';
import { env, createExecutionContext, waitOnExecutionContext } from "cloudflare:test";
import { describe, it, expect } from "vitest";
import worker from "../src/index";

// For now, you'll need to do something like this to get a correctly-typed
// `Request` to pass to `worker.fetch()`.
const IncomingRequest = Request<unknown, IncomingRequestCfProperties>;

describe('Hello World worker', () => {
it('responds with Hello World! (unit style)', async () => {
const request = new IncomingRequest('http://example.com');
// Create an empty context to pass to `worker.fetch()`.
describe("CORS configuration", () => {
it("returns CORS headers and credentials for allowlisted origin preflight", async () => {
const request = new IncomingRequest("http://example.com/v1/chat/completions", {
method: "OPTIONS",
headers: {
Origin: "https://app.example.com",
"Access-Control-Request-Method": "POST"
}
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, env, ctx);
// Wait for all `Promise`s passed to `ctx.waitUntil()` to settle before running test assertions
const response = await worker.fetch(
request,
{ ...env, ALLOWED_ORIGINS: "https://app.example.com,https://admin.example.com" },
ctx
);
await waitOnExecutionContext(ctx);
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);

expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example.com");
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
expect(response.headers.get("Access-Control-Allow-Methods")).toBe("POST,GET,OPTIONS");
expect(response.headers.get("Access-Control-Allow-Headers")).toBe("Content-Type,Authorization");
});

it("does not return origin or credential headers for disallowed origin preflight", async () => {
const request = new IncomingRequest("http://example.com/v1/chat/completions", {
method: "OPTIONS",
headers: {
Origin: "https://evil.example.com",
"Access-Control-Request-Method": "POST"
}
});
const ctx = createExecutionContext();
const response = await worker.fetch(
request,
{ ...env, ALLOWED_ORIGINS: "https://app.example.com" },
ctx
);
await waitOnExecutionContext(ctx);

expect(response.status).toBe(204);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Access-Control-Allow-Credentials")).toBeNull();
});

it("returns origin and credentials for allowlisted origin on regular responses", async () => {
const request = new IncomingRequest("http://example.com/health", {
headers: {
Origin: "https://app.example.com"
}
});
const ctx = createExecutionContext();
const response = await worker.fetch(
request,
{ ...env, ALLOWED_ORIGINS: "https://app.example.com" },
ctx
);
await waitOnExecutionContext(ctx);

expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBe("https://app.example.com");
expect(response.headers.get("Access-Control-Allow-Credentials")).toBe("true");
});

it('responds with Hello World! (integration style)', async () => {
const response = await SELF.fetch('https://example.com');
expect(await response.text()).toMatchInlineSnapshot(`"Hello World!"`);
it("omits credentials header when no origin is allowlisted", async () => {
const request = new IncomingRequest("http://example.com/health", {
headers: {
Origin: "https://app.example.com"
}
});
const ctx = createExecutionContext();
const response = await worker.fetch(request, { ...env, ALLOWED_ORIGINS: "" }, ctx);
await waitOnExecutionContext(ctx);

expect(response.status).toBe(200);
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
expect(response.headers.get("Access-Control-Allow-Credentials")).toBeNull();
});
});
2 changes: 1 addition & 1 deletion vitest.config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ export default defineWorkersConfig({
test: {
poolOptions: {
workers: {
wrangler: { configPath: './wrangler.jsonc' },
wrangler: { configPath: './wrangler.toml' },
},
},
},
Expand Down