Summary
The Set-Cookie rewrite in compose/seatable-server.yml:64 never takes effect. Every cookie keeps the Django defaults, SameSite=Lax and no Secure, so a base embedded on another domain loads but shows no images or attachments.
caddy_0.header.Set-Cookie: '(.*) "$1; SameSite=None; Secure"'
Responses through Caddy and responses taken directly from the backend nginx are byte for byte identical, on HTTP/1.1 and HTTP/2 alike. It affects sessionid, dtable_csrftoken and saml_session.
SeaTable Cloud is not affected — cloud.seatable.io serves its cookies correctly with SameSite=None; Secure on the same 6.2.13. The difference is in the deployment configuration, not in the application.
Why it does not work
Caddy's headers handler applies response operations to the still empty header map before calling the next handler, unless the block is deferred. For set operations that makes no difference, which is why CSP, HSTS and X-Frame-Options all arrive correctly. A replace operation, however, finds no Set-Cookie to replace at that point — the reverse proxy sets it afterwards.
The generated configuration carries no deferred flag, as served by the Caddy admin API under /config/apps/http/servers/srv0/routes/0/handle/0/routes/0/handle/0. Adding it to that one handler at runtime is enough to make the rewrite work:
# as shipped
set-cookie: sessionid=…; HttpOnly; Max-Age=86400; Path=/; SameSite=Lax
# same handler, "deferred": true
set-cookie: sessionid=…; HttpOnly; Max-Age=86400; Path=/; SameSite=Lax; SameSite=None; Secure
What breaks
An embedded page carries its own token inline in the HTML (window.app.accessToken), so the frame and the data do not depend on cookies. Assets do. Images and attachments are served by dtable_asset_access, and can_access_asset() reaches for the Django session in three places:
| check |
source |
can_access_file_through_external_link |
request.session['external_link'] |
has_dtable_asset_cache_read_permission |
request.session session key |
can_access_asset_through_external_app |
request.session['external_app'] |
The other way in, @bind_token_user, needs an Authorization header, which a browser cannot send for <img src=…>. So in a cross-site iframe the browser withholds the SameSite=Lax cookie — worse, it refuses to store it in the first place, so external_link never reaches the session at all. The same asset request, with and against without the session cookie:
with session cookie 200 image/png size=7 image is served
without cookie 200 text/html size=6174 "Permission denied."
Net effect for a self-hosted installation: an embedded base renders, the data appears, and every image and attachment stays blank.
Suggested fix
Move the cookie line into a header block of its own and defer it:
- caddy_0.header.Set-Cookie: '(.*) "$1; SameSite=None; Secure"'
+ # Cookie attributes need their own deferred header block: a replace
+ # operation only takes effect while the response is being written.
+ caddy_0.header_1.defer: ""
+ caddy_0.header_1.Set-Cookie: '`^(.*?)(?:;\s*SameSite=[^;]*)?(?:;\s*Secure)?$` `$1; SameSite=None; Secure`'
Two things worth knowing before changing it:
Do not simply add defer to the existing block. defer applies to the whole block, and that block also sets the CSP. The routes /dtable/forms/*, /dtable/external-links/*, /dtable/external-apps/*, /apps/custom/* and /external-apps/* remove it again with header.-Content-Security-Policy, which only works because the CSP is set first. Deferring the whole block puts the CSP back afterwards and breaks the very embedding this is meant to fix.
The regex replaces rather than appends. With defer alone the result is SameSite=Lax; Secure; SameSite=None; Secure. That works — the last value wins per RFC 6265bis — but the anchored expression above produces a clean SameSite=None; Secure.
Verified on a 6.2.13 instance: cookies come out with SameSite=None; Secure, / keeps its CSP, and the embed routes stay CSP-free.
A better option, and what it depends on
Dropping the label and setting the attributes where they belong, in Django — SESSION_COOKIE_SECURE, SESSION_COOKIE_SAMESITE, CSRF_COOKIE_SECURE, CSRF_COOKIE_SAMESITE — removes the regex across all cookies, the ordering pitfall around defer, and puts the setting somewhere greppable instead of hiding it in a proxy label. Since SESSION_COOKIE_SECURE arrives with 7.0 through seafileltd/dtable-web#6291 anyway, that would be the consistent path.
Two things have to be decided along with it, otherwise that move trades one problem for another.
SameSite=Lax on sessionid is a real CSRF defence today. Relaxing it to None — which is what embedding needs — leaves only the Django CSRF token, and CSRF_TRUSTED_ORIGINS currently reads:
CSRF_TRUSTED_ORIGINS = ["https://*", "http://*", "chrome-extension://*"]
That trusts effectively any origin, so the origin check contributes nothing. Loosening SameSite without tightening this would fix the embedding and weaken CSRF at the same time. Whether CSRF_TRUSTED_ORIGINS is narrowed in the same step is a product decision, not a technical detail, and it is the reason this is worth deciding rather than just doing.
The Django settings do not reach every cookie. Several cookies are written by hand with no flags at all, so they bypass SESSION_COOKIE_* entirely:
# seahub/auth/middleware.py, seahub/saml/views.py, seahub/oauth/views.py and others
response.set_cookie('seahub_auth', request.user.username + '@' + api_token.key)
# seahub/dtable/views.py
response.set_cookie('access-token', access_token)
seahub_auth carries username@api_token, a bearer credential, and gets neither HttpOnly nor Secure. Observable on any instance with GET /accounts/login/?source=partner, which answers with Set-Cookie: REGISTRATION_SOURCE=partner; Path=/ — the same flagless call shape. Fixing the defaults without also passing secure, httponly and samesite at these call sites would leave the most sensitive cookie untouched.
Remaining limitation
Browsers that block third-party cookies outright rather than partitioning them — Chrome in incognito, Safari with ITP — will still not show the images after this fix. Firefox is fine, because Total Cookie Protection partitions and the whole exchange happens inside the one iframe. The durable answer would be to stop authorizing assets by cookie; the token is already in the page as window.app.accessToken.
Summary
The
Set-Cookierewrite incompose/seatable-server.yml:64never takes effect. Every cookie keeps the Django defaults,SameSite=Laxand noSecure, so a base embedded on another domain loads but shows no images or attachments.Responses through Caddy and responses taken directly from the backend nginx are byte for byte identical, on HTTP/1.1 and HTTP/2 alike. It affects
sessionid,dtable_csrftokenandsaml_session.SeaTable Cloud is not affected —
cloud.seatable.ioserves its cookies correctly withSameSite=None; Secureon the same 6.2.13. The difference is in the deployment configuration, not in the application.Why it does not work
Caddy's
headershandler applies response operations to the still empty header map before calling the next handler, unless the block is deferred. Forsetoperations that makes no difference, which is why CSP, HSTS andX-Frame-Optionsall arrive correctly. Areplaceoperation, however, finds noSet-Cookieto replace at that point — the reverse proxy sets it afterwards.The generated configuration carries no
deferredflag, as served by the Caddy admin API under/config/apps/http/servers/srv0/routes/0/handle/0/routes/0/handle/0. Adding it to that one handler at runtime is enough to make the rewrite work:What breaks
An embedded page carries its own token inline in the HTML (
window.app.accessToken), so the frame and the data do not depend on cookies. Assets do. Images and attachments are served bydtable_asset_access, andcan_access_asset()reaches for the Django session in three places:can_access_file_through_external_linkrequest.session['external_link']has_dtable_asset_cache_read_permissionrequest.sessionsession keycan_access_asset_through_external_apprequest.session['external_app']The other way in,
@bind_token_user, needs anAuthorizationheader, which a browser cannot send for<img src=…>. So in a cross-site iframe the browser withholds theSameSite=Laxcookie — worse, it refuses to store it in the first place, soexternal_linknever reaches the session at all. The same asset request, with and against without the session cookie:Net effect for a self-hosted installation: an embedded base renders, the data appears, and every image and attachment stays blank.
Suggested fix
Move the cookie line into a header block of its own and defer it:
Two things worth knowing before changing it:
Do not simply add
deferto the existing block.deferapplies to the whole block, and that block also sets the CSP. The routes/dtable/forms/*,/dtable/external-links/*,/dtable/external-apps/*,/apps/custom/*and/external-apps/*remove it again withheader.-Content-Security-Policy, which only works because the CSP is set first. Deferring the whole block puts the CSP back afterwards and breaks the very embedding this is meant to fix.The regex replaces rather than appends. With
deferalone the result isSameSite=Lax; Secure; SameSite=None; Secure. That works — the last value wins per RFC 6265bis — but the anchored expression above produces a cleanSameSite=None; Secure.Verified on a 6.2.13 instance: cookies come out with
SameSite=None; Secure,/keeps its CSP, and the embed routes stay CSP-free.A better option, and what it depends on
Dropping the label and setting the attributes where they belong, in Django —
SESSION_COOKIE_SECURE,SESSION_COOKIE_SAMESITE,CSRF_COOKIE_SECURE,CSRF_COOKIE_SAMESITE— removes the regex across all cookies, the ordering pitfall arounddefer, and puts the setting somewhere greppable instead of hiding it in a proxy label. SinceSESSION_COOKIE_SECUREarrives with 7.0 through seafileltd/dtable-web#6291 anyway, that would be the consistent path.Two things have to be decided along with it, otherwise that move trades one problem for another.
SameSite=Laxonsessionidis a real CSRF defence today. Relaxing it toNone— which is what embedding needs — leaves only the Django CSRF token, andCSRF_TRUSTED_ORIGINScurrently reads:That trusts effectively any origin, so the origin check contributes nothing. Loosening
SameSitewithout tightening this would fix the embedding and weaken CSRF at the same time. WhetherCSRF_TRUSTED_ORIGINSis narrowed in the same step is a product decision, not a technical detail, and it is the reason this is worth deciding rather than just doing.The Django settings do not reach every cookie. Several cookies are written by hand with no flags at all, so they bypass
SESSION_COOKIE_*entirely:seahub_authcarriesusername@api_token, a bearer credential, and gets neitherHttpOnlynorSecure. Observable on any instance withGET /accounts/login/?source=partner, which answers withSet-Cookie: REGISTRATION_SOURCE=partner; Path=/— the same flagless call shape. Fixing the defaults without also passingsecure,httponlyandsamesiteat these call sites would leave the most sensitive cookie untouched.Remaining limitation
Browsers that block third-party cookies outright rather than partitioning them — Chrome in incognito, Safari with ITP — will still not show the images after this fix. Firefox is fine, because Total Cookie Protection partitions and the whole exchange happens inside the one iframe. The durable answer would be to stop authorizing assets by cookie; the token is already in the page as
window.app.accessToken.