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
18 changes: 17 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,21 @@ docker pull ghcr.io/decohererk/decotv:v0.9.0

> **注意**:使用 `latest` 标签时,重启容器不会自动拉取新镜像,需要手动执行 `docker pull` 才能获取更新。使用版本号标签可以明确控制何时更新。

### 访问协议与反向代理

DecoTV 支持直接通过 Docker 端口映射在局域网 HTTP 地址访问,例如 `http://192.168.1.10:3000`。这种场景下登录 Cookie 不会设置 `Secure` 属性,浏览器可以正常保存认证状态。

公网部署强烈建议使用 HTTPS。若前面有 Nginx/OpenResty 等反向代理,请确保把外部访问协议传给 DecoTV:

```nginx
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
```

DecoTV 会根据请求 URL、`X-Forwarded-Proto` 或标准 `Forwarded` 头判断实际协议。HTTPS 访问会设置 `Secure` Cookie;HTTP 局域网直连和 HTTP 反代不会设置 `Secure` Cookie。不要仅依赖容器内的 `NODE_ENV=production` 判断访问协议。

### Kvrocks 存储(推荐)

```yml
Expand Down Expand Up @@ -331,7 +346,8 @@ volumes:

1. **浏览器 Cookie 问题**:尝试清除浏览器 Cookie 后重新登录
2. **残留数据库配置**:确保没有设置 `REDIS_URL`、`KV_REST_API_URL` 等数据库变量
3. **使用了 HTTPS 代理**:如果你通过 Nginx 等反向代理使用 HTTPS,确保正确配置了 `X-Forwarded-Proto` 头
3. **反向代理协议头缺失**:如果你通过 Nginx/OpenResty 等反向代理使用 HTTPS,确保正确配置 `X-Forwarded-Proto $scheme`,否则应用可能无法按外部访问协议设置 Cookie
4. **镜像未更新**:`latest` 镜像重启不会自动拉取新版本,升级前需要先执行 `docker pull ghcr.io/decohererk/decotv:latest`

**Q: 如何从本地模式迁移到数据库模式?**

Expand Down
143 changes: 143 additions & 0 deletions __tests__/auth-cookie.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
/* global describe, expect, it */

const {
getAuthCookieClearOptions,
getAuthCookieOptions,
} = require('../src/lib/auth-cookie');
const {
getEffectiveRequestProtocol,
isSecureRequest,
} = require('../src/lib/request-protocol');
const { getSafeRedirectPath } = require('../src/lib/safe-redirect');

function requestLike(url, headers = {}) {
return {
headers: new Headers(headers),
nextUrl: new URL(url),
};
}

describe('request protocol detection', () => {
it('does not treat production mode as HTTPS', () => {
const originalNodeEnv = process.env.NODE_ENV;
process.env.NODE_ENV = 'production';

const request = requestLike('http://192.168.1.20:3334/api/login');

expect(getEffectiveRequestProtocol(request)).toBe('http');
expect(isSecureRequest(request)).toBe(false);

process.env.NODE_ENV = originalNodeEnv;
});

it('detects direct HTTPS requests', () => {
const request = requestLike('https://example.com/api/login');

expect(getEffectiveRequestProtocol(request)).toBe('https');
expect(isSecureRequest(request)).toBe(true);
});

it('uses the first X-Forwarded-Proto value from a proxy chain', () => {
const request = requestLike('http://127.0.0.1:3000/api/login', {
'x-forwarded-proto': ' HTTPS, http ',
});

expect(getEffectiveRequestProtocol(request)).toBe('https');
expect(isSecureRequest(request)).toBe(true);
});

it('falls back to the standard Forwarded proto value', () => {
const request = requestLike('http://127.0.0.1:3000/api/login', {
forwarded: 'for=192.0.2.60;proto="https";host=example.com',
});

expect(getEffectiveRequestProtocol(request)).toBe('https');
expect(isSecureRequest(request)).toBe(true);
});

it('honors an HTTP reverse proxy header', () => {
const request = requestLike('http://127.0.0.1:3000/api/login', {
'x-forwarded-proto': 'http',
});

expect(getEffectiveRequestProtocol(request)).toBe('http');
expect(isSecureRequest(request)).toBe(false);
});
});

describe('auth cookie options', () => {
it('sets HTTP IP cookies without Secure and never uses SameSite=None', () => {
const request = requestLike('http://192.168.1.20:3334/api/login');
const options = getAuthCookieOptions(request, new Date('2026-01-01'));

expect(options.secure).toBe(false);
expect(options.sameSite).toBe('lax');
expect(options.httpOnly).toBe(false);
expect(options.path).toBe('/');
});

it('sets localhost HTTP cookies without relying on Secure cookie exceptions', () => {
const request = requestLike('http://localhost:3000/api/login');
const options = getAuthCookieOptions(request, new Date('2026-01-01'));

expect(options.secure).toBe(false);
expect(options.sameSite).toBe('lax');
});

it('sets Secure only for effective HTTPS requests', () => {
expect(
getAuthCookieOptions(
requestLike('https://example.com/api/login'),
new Date('2026-01-01'),
).secure,
).toBe(true);

expect(
getAuthCookieOptions(
requestLike('http://127.0.0.1:3000/api/login', {
'x-forwarded-proto': 'https',
}),
new Date('2026-01-01'),
).secure,
).toBe(true);
});

it('uses matching path and security attributes when clearing cookies', () => {
const httpOptions = getAuthCookieClearOptions(
requestLike('http://192.168.1.20:3334/api/logout'),
);
const httpsOptions = getAuthCookieClearOptions(
requestLike('https://example.com/api/logout'),
);

expect(httpOptions.path).toBe('/');
expect(httpOptions.maxAge).toBe(0);
expect(httpOptions.secure).toBe(false);
expect(httpOptions.sameSite).toBe('lax');

expect(httpsOptions.path).toBe('/');
expect(httpsOptions.maxAge).toBe(0);
expect(httpsOptions.secure).toBe(true);
expect(httpsOptions.sameSite).toBe('lax');
});
});

describe('safe login redirects', () => {
it.each(['/', '/search?q=test', '/play?source=x&id=1'])(
'allows internal redirect %s',
(redirect) => {
expect(getSafeRedirectPath(redirect)).toBe(redirect);
},
);

it.each([
'https://evil.example',
'http://evil.example',
'//evil.example',
'javascript:alert(1)',
'',
null,
])('rejects unsafe redirect %s', (redirect) => {
expect(getSafeRedirectPath(redirect)).toBe('/');
});
});
Loading