fix: preserve browser form origins for update panel authentication
This commit is contained in:
@@ -52,6 +52,21 @@ DATA_DIR=/app/data
|
|||||||
|
|
||||||
`PUBLIC_ORIGIN`不加结尾斜杠或路径,必须使用浏览器实际访问的HTTPS域名。可以自行修改ADMIN_USERNAME。密码以scrypt哈希保存;明文密码不写入APP或源码。SESSION_SECRET是更新后台自己的会话密钥,与APK签名密钥和反馈服务密钥无关。日后改密码时可重新运行工具并替换上述两行,重建服务使原登录失效。
|
`PUBLIC_ORIGIN`不加结尾斜杠或路径,必须使用浏览器实际访问的HTTPS域名。可以自行修改ADMIN_USERNAME。密码以scrypt哈希保存;明文密码不写入APP或源码。SESSION_SECRET是更新后台自己的会话密钥,与APK签名密钥和反馈服务密钥无关。日后改密码时可重新运行工具并替换上述两行,重建服务使原登录失效。
|
||||||
|
|
||||||
|
如果登录提示“请求来源无效,请从本站更新后台提交”:旧版服务使用`Referrer-Policy: no-referrer`,会使浏览器正常表单POST也发送`Origin: null`,与后台的同源检查冲突。已修正为`same-origin`:同源表单保留来源,跨站不发送Referrer,来源与CSRF校验保持启用。将修正包内`update_service.py`覆盖原部署目录的同名文件,保留`.env`与数据卷,执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd /opt/tpschedule-updates
|
||||||
|
docker compose up -d --build --force-recreate updates
|
||||||
|
```
|
||||||
|
|
||||||
|
重新打开`https://app.glowtps.cn/update`后再登录,不能继续提交更新前已打开的旧页面。如果仍提示来源无效,只查看容器的公开域名配置(不打印密码或会话密钥):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
docker compose exec updates python -c 'import os; print(os.environ.get("PUBLIC_ORIGIN"))'
|
||||||
|
```
|
||||||
|
|
||||||
|
输出必须是`https://app.glowtps.cn`(无路径、无末尾斜杠,不是内部HTTP地址);访问地址也必须使用这个HTTPS域名。确认雷池/OpenResty没有覆盖`Origin`请求头或追加旧的`Referrer-Policy: no-referrer`,且不缓存后台页面。不要硬编码伪造`Origin`、放行`null`或关闭CSRF校验。
|
||||||
|
|
||||||
### 3. 启动并检查容器
|
### 3. 启动并检查容器
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
// Run against a fresh, disposable HTTPS update server. Never use production credentials.
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const base = process.env.UPDATE_SMOKE_ORIGIN;
|
||||||
|
assert(base && base.startsWith('https://127.0.0.1:'), 'Use a local disposable HTTPS server');
|
||||||
|
const browser = await chromium.launch({ channel: 'msedge', headless: true });
|
||||||
|
try {
|
||||||
|
const context = await browser.newContext({ ignoreHTTPSErrors: true });
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(base + '/update');
|
||||||
|
await page.locator('input[name="username"]').fill('admin');
|
||||||
|
await page.locator('input[name="password"]').fill('only-for-tests-password');
|
||||||
|
const loginResponse = page.waitForResponse(r => r.url() === base + '/update/login');
|
||||||
|
await page.locator('form[action="/update/login"] button').click();
|
||||||
|
const login = await loginResponse;
|
||||||
|
const headers = await login.request().allHeaders();
|
||||||
|
console.log(JSON.stringify({ loginStatus: login.status(), browserOrigin: headers.origin }));
|
||||||
|
assert.equal(login.status(), 303, 'Real browser form login must succeed');
|
||||||
|
assert.equal(headers.origin, base, 'Same-origin forms must preserve their Origin');
|
||||||
|
await page.locator('input[name="version"]').fill('0.8.0');
|
||||||
|
await page.locator('input[name="build_number"]').fill('25');
|
||||||
|
await page.locator('textarea[name="notes"]').fill('浏览器表单回归验证');
|
||||||
|
await page.locator('input[name="download_url"]').fill('https://example.com/test.apk');
|
||||||
|
const publishResponse = page.waitForResponse(r => r.url() === base + '/update/publish');
|
||||||
|
await page.locator('form[action="/update/publish"] button').click();
|
||||||
|
assert.equal((await publishResponse).status(), 303, 'Browser publishing must succeed');
|
||||||
|
const manifest = await (await context.request.get(base + '/v1/updates/android')).json();
|
||||||
|
assert.equal(manifest.latest.build_number, 25);
|
||||||
|
assert.equal(manifest.latest.notes, '浏览器表单回归验证');
|
||||||
|
const logoutResponse = page.waitForResponse(r => r.url() === base + '/update/logout');
|
||||||
|
await page.locator('form[action="/update/logout"] button').click();
|
||||||
|
assert.equal((await logoutResponse).status(), 303, 'Browser logout must succeed');
|
||||||
|
await page.locator('input[name="password"]').waitFor();
|
||||||
|
console.log('Real HTTPS browser login, publication and logout passed.');
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
|
})().catch(error => { console.error(error); process.exitCode = 1; });
|
||||||
@@ -101,6 +101,23 @@ def test_csrf_and_cross_origin_login_publish_logout_are_rejected(service):
|
|||||||
assert client.post('/update/logout', data={'csrf': 'bad'}, headers={'Origin': BASE}).status_code == 403
|
assert client.post('/update/logout', data={'csrf': 'bad'}, headers={'Origin': BASE}).status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize('origin', [None, 'null', 'https://evil.test'])
|
||||||
|
def test_valid_csrf_does_not_allow_missing_null_or_foreign_origin(service, origin):
|
||||||
|
client, _ = service
|
||||||
|
headers = {} if origin is None else {'Origin': origin}
|
||||||
|
page = client.get('/update')
|
||||||
|
values = {'username': 'admin', 'password': PASSWORD, 'csrf': csrf(page)}
|
||||||
|
assert client.post('/update/login', data=values, headers=headers).status_code == 403
|
||||||
|
assert login(client).status_code == 303
|
||||||
|
page = client.get('/update')
|
||||||
|
values = {'csrf': csrf(page), 'version': '0.8.0', 'build_number': '25',
|
||||||
|
'notes': '不得发布', 'download_url': BASE + '/test.apk', 'revision': 'none'}
|
||||||
|
assert client.post('/update/publish', data=values, headers=headers).status_code == 403
|
||||||
|
assert client.get('/v1/updates/android').json()['latest'] is None
|
||||||
|
assert client.post('/update/logout', data={'csrf': csrf(page)}, headers=headers).status_code == 403
|
||||||
|
assert 'name="download_url"' in client.get('/update').text
|
||||||
|
|
||||||
|
|
||||||
def test_login_rejects_unicode_username_and_limits_password_guesses(service):
|
def test_login_rejects_unicode_username_and_limits_password_guesses(service):
|
||||||
client, _ = service
|
client, _ = service
|
||||||
page = client.get('/update')
|
page = client.get('/update')
|
||||||
|
|||||||
@@ -229,7 +229,10 @@ class RequestBounds:
|
|||||||
if message['type'] == 'http.response.start':
|
if message['type'] == 'http.response.start':
|
||||||
message['headers'] += [
|
message['headers'] += [
|
||||||
(b'cache-control', b'no-store'), (b'x-content-type-options', b'nosniff'),
|
(b'cache-control', b'no-store'), (b'x-content-type-options', b'nosniff'),
|
||||||
(b'referrer-policy', b'no-referrer'), (b'x-frame-options', b'DENY'),
|
# no-referrer makes native form POSTs send Origin: null.
|
||||||
|
# Keep same-origin form authentication working without
|
||||||
|
# disclosing referrers to other sites.
|
||||||
|
(b'referrer-policy', b'same-origin'), (b'x-frame-options', b'DENY'),
|
||||||
(b'content-security-policy', b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
|
(b'content-security-policy', b"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; frame-ancestors 'none'; base-uri 'none'"),
|
||||||
]
|
]
|
||||||
await send(message)
|
await send(message)
|
||||||
|
|||||||
Reference in New Issue
Block a user